diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5bebcec8..f1a4bda0 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 with: @@ -29,33 +29,21 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Setup poetry - uses: abatilo/actions-poetry@v4 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - poetry-version: '2.1.4' - - - name: Setup a local virtual environment - run: | - poetry env use ${{ steps.setup-python.outputs.python-path }} - poetry run python --version - poetry config virtualenvs.create true --local - poetry config virtualenvs.in-project true --local + enable-cache: true - uses: actions/cache@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('poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || (echo "Cache is broken, skip it" && rm -rf .venv) + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('uv.lock') }} - name: Install dependencies - run: poetry install -E crypto + run: uv sync --extra crypto --extra dev - name: Generate rest sync code and tests - run: poetry run unasync + run: uv run unasync - name: Test with pytest - run: poetry run pytest --verbose --tb=short --reruns 3 + run: uv run pytest --verbose --tb=short --capture=no diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1b1b86b3..90e54327 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,31 +19,19 @@ jobs: with: python-version: '3.9' - - name: Setup poetry - uses: abatilo/actions-poetry@v4 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - poetry-version: '2.1.4' - - - name: Setup a local virtual environment - run: | - poetry env use ${{ steps.setup-python.outputs.python-path }} - poetry run python --version - poetry config virtualenvs.create true --local - poetry config virtualenvs.in-project true --local + enable-cache: true - uses: actions/cache@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('poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || (echo "Cache is broken, skip it." && rm -rf .venv) + key: venv-${{ runner.os }}-3.9-${{ hashFiles('uv.lock') }} - name: Install dependencies - run: poetry install - - name: Lint with flake8 - run: poetry run flake8 + 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 index 01247cfe..23326f8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,36 +21,24 @@ jobs: with: python-version: 3.12 - - name: Setup poetry - uses: abatilo/actions-poetry@v4 + - name: Install uv + uses: astral-sh/setup-uv@v7 with: - poetry-version: '1.8.5' - - - name: Setup a local virtual environment - run: | - poetry env use ${{ steps.setup-python.outputs.python-path }} - poetry run python --version - poetry config virtualenvs.create true --local - poetry config virtualenvs.in-project true --local + enable-cache: true - uses: actions/cache@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('poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || (echo "Cache is broken, skip it" && rm -rf .venv) + key: venv-${{ runner.os }}-3.12-${{ hashFiles('uv.lock') }} - name: Install dependencies - run: poetry install -E crypto + run: uv sync --extra crypto --extra dev - name: Generate rest sync code and tests - run: poetry run unasync + run: uv run unasync - name: Build a binary wheel and a source tarball - run: poetry build + run: uv build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: @@ -64,6 +52,7 @@ jobs: 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 @@ -74,6 +63,7 @@ jobs: 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 @@ -144,4 +134,4 @@ jobs: - name: Publish distribution 📦 to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - repository-url: https://test.pypi.org/legacy/ \ No newline at end of file + repository-url: https://test.pypi.org/legacy/ diff --git a/.gitignore b/.gitignore index 90697255..75ec0f34 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,5 @@ ably/types/options.py.orig test/ably/restsetup.py.orig .idea/**/* -**/ably/sync/*** +ably/sync/** +test/ably/sync/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 834fa33c..bcde3f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Change Log +## [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) 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 index 67cf9b3a..14ebf54b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ ### Initialising -ably-python uses [Poetry](https://python-poetry.org/) for packaging and dependency management. Please refer to the [Poetry documentation](https://python-poetry.org/docs/#installation) for up to date instructions on how to install Poetry. +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: @@ -12,13 +12,13 @@ Perform the following operations after cloning the repository contents: git submodule init git submodule update # Install the crypto extra if you wish to be able to run all of the tests -poetry install -E crypto +uv sync --extra crypto ``` ### Running the test suite ```shell -poetry run pytest +uv run pytest ``` ## Release Process @@ -38,11 +38,10 @@ The release process must include the following steps: 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. Build the synchronous REST client by running `poetry run unasync` -9. From the `main` branch, run `poetry build && poetry publish` (will require you to have a PyPi API token, see [guide](https://www.digitalocean.com/community/tutorials/how-to-publish-python-packages-to-pypi-using-poetry-on-ubuntu-22-04)) to build and upload this new package to PyPi -10. 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` -11. Create the release on GitHub including populating the release notes -12. Update the [Ably Changelog](https://changelog.ably.com/) (via [headwayapp](https://headwayapp.co/)) with these changes +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: diff --git a/README.md b/README.md index 34965aa9..4ee29fd5 100644 --- a/README.md +++ b/README.md @@ -31,15 +31,15 @@ Ably aims to support a wide range of platforms. If you experience any compatibil The following platforms are supported: -| Platform | Support | -|----------|---------| -| Python | Python 3.7+ through 3.13 | +| Platform | Support | +|----------|--------------------------| +| Python | Python 3.7+ through 3.14 | > [!NOTE] > This SDK works across all major operating platforms (Linux, macOS, Windows) as long as Python 3.7+ is available. > [!IMPORTANT] -> SDK versions < 2.0.0-beta.6 will be [deprecated](https://ably.com/docs/platform/deprecate/protocol-v1) from November 1, 2025. +> SDK versions < 2.0.0 are [deprecated](https://ably.com/docs/platform/deprecate/protocol-v1). --- diff --git a/UPDATING.md b/UPDATING.md index fff56553..4b4dd719 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -1,5 +1,95 @@ # 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). diff --git a/ably/__init__.py b/ably/__init__.py index d1c12f01..076f1ef1 100644 --- a/ably/__init__.py +++ b/ably/__init__.py @@ -1,19 +1,24 @@ -from ably.rest.rest import AblyRest +import logging + 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.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.vcdiff.default_vcdiff_decoder import AblyVCDiffDecoder - -import logging +from ably.util.exceptions import AblyAuthException, AblyException, IncompatibleClientIdException +from ably.vcdiff.defaultvcdiffdecoder import AblyVCDiffDecoder logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -api_version = '3' -lib_version = '2.1.2' +api_version = '5' +lib_version = '3.1.0' diff --git a/ably/http/http.py b/ably/http/http.py index 45367eef..d21a9386 100644 --- a/ably/http/http.py +++ b/ably/http/http.py @@ -1,17 +1,17 @@ import functools +import json import logging import time -import json from urllib.parse import urljoin 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 -from ably.util.helper import is_token_error, extract_url_params +from ably.util.helper import extract_url_params, is_token_error log = logging.getLogger(__name__) @@ -140,9 +140,9 @@ def dump_body(self, body): else: return json.dumps(body, separators=(',', ':')) - def get_rest_hosts(self): - hosts = self.options.get_rest_hosts() - host = self.__host or self.options.fallback_realtime_host + def get_hosts(self): + hosts = self.options.get_hosts() + host = self.__host or self.options.fallback_host if host is None: return hosts @@ -186,16 +186,14 @@ async def make_request(self, method, path, version=None, 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): - def should_stop_retrying(): + 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 = "%s://%s:%d" % (self.preferred_scheme, - host, - self.preferred_port) + base_url = f"{self.preferred_scheme}://{host}:{self.preferred_port}" url = urljoin(base_url, path) (clean_url, url_params) = extract_url_params(url) @@ -231,7 +229,7 @@ def 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) @@ -279,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 b55ae75c..aca46b0f 100644 --- a/ably/http/httputils.py +++ b/ably/http/httputils.py @@ -42,7 +42,7 @@ def default_headers(version=None): version = ably.api_version return { "X-Ably-Version": version, - "Ably-Agent": 'ably-python/%s python/%s' % (ably.lib_version, platform.python_version()) + "Ably-Agent": f'ably-python/{ably.lib_version} python/{platform.python_version()}' } @staticmethod diff --git a/ably/http/paginatedresult.py b/ably/http/paginatedresult.py index 6421251b..a034d9d1 100644 --- a/ably/http/paginatedresult.py +++ b/ably/http/paginatedresult.py @@ -10,7 +10,7 @@ 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) @@ -33,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'") 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/realtime_channel.py b/ably/realtime/channel.py similarity index 55% rename from ably/realtime/realtime_channel.py rename to ably/realtime/channel.py index e75e8c56..768eeb7d 100644 --- a/ably/realtime/realtime_channel.py +++ b/ably/realtime/channel.py @@ -2,89 +2,33 @@ import asyncio import logging -from typing import Optional, TYPE_CHECKING, Dict, Any +from typing import TYPE_CHECKING + +from ably.realtime.annotations import RealtimeAnnotations from ably.realtime.connection import ConnectionState -from ably.rest.channel import Channel, Channels as RestChannels +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 +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 -from ably.util.helper import Timer, is_callable_or_coroutine +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 - from ably.util.crypto import CipherParams log = logging.getLogger(__name__) -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: Optional[CipherParams] = None, params: Optional[dict] = None): - self.__cipher = cipher - self.__params = params - # Validate params - if self.__params and not isinstance(self.__params, dict): - raise AblyException("params must be a dictionary", 40000, 400) - - @property - def cipher(self): - """Get cipher configuration""" - return self.__cipher - - @property - def params(self) -> Dict[str, str]: - """Get channel parameters""" - return self.__params - - 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) - - def __hash__(self): - """Make ChannelOptions hashable""" - return hash(( - self.__cipher, - tuple(sorted(self.__params.items())) if self.__params 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 - 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'), - ) - - class RealtimeChannel(EventEmitter, Channel): """ Ably Realtime Channel @@ -110,20 +54,21 @@ class RealtimeChannel(EventEmitter, Channel): Unsubscribe to messages from a channel """ - def __init__(self, realtime: AblyRealtime, name: str, channel_options: Optional[ChannelOptions] = None): + 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: Optional[Timer] = None + self.__state_timer: Timer | None = None self.__attach_resume = False - self.__attach_serial: Optional[str] = None - self.__channel_serial: Optional[str] = None - self.__retry_timer: Optional[Timer] = None - self.__error_reason: Optional[AblyException] = None + 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: Optional[Dict[str, str]] = None + 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 @@ -137,6 +82,13 @@ def __init__(self, realtime: AblyRealtime, name: str, channel_options: Optional[ # 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) @@ -205,8 +157,10 @@ def _attach_impl(self): "channel": self.name, } - if self.__attach_resume: - attach_msg["flags"] = Flag.ATTACH_RESUME + flags = self._encode_flags() + + if flags: + attach_msg["flags"] = flags if self.__channel_serial: attach_msg["channelSerial"] = self.__channel_serial @@ -382,6 +336,357 @@ def unsubscribe(self, *args) -> None: # 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, + ) + + # 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 @@ -394,6 +699,7 @@ def _on_message(self, proto_msg: dict) -> None: error = proto_msg.get("error") exception = None resumed = False + has_presence = False self.__attach_serial = channel_serial self.__channel_serial = channel_serial @@ -404,6 +710,10 @@ def _on_message(self, proto_msg: dict) -> None: 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: @@ -411,7 +721,7 @@ def _on_message(self, proto_msg: dict) -> None: 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) + 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: @@ -424,7 +734,8 @@ def _on_message(self, proto_msg: dict) -> None: elif action == ProtocolMessageAction.MESSAGE: messages = [] try: - messages = Message.from_encoded_array(proto_msg.get('messages'), context=self.__decoding_context) + 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: @@ -434,6 +745,30 @@ def _on_message(self, proto_msg: dict) -> None: 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) @@ -443,8 +778,8 @@ def _request_state(self, state: ChannelState) -> None: self._notify_state(state) self._check_pending_state() - def _notify_state(self, state: ChannelState, reason: Optional[AblyException] = None, - resumed: bool = False) -> None: + 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() @@ -482,6 +817,9 @@ def _notify_state(self, state: ChannelState, reason: Optional[AblyException] = N 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)) @@ -563,15 +901,29 @@ def state(self, state: ChannelState) -> None: # RTL24 @property - def error_reason(self) -> Optional[AblyException]: + 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]: + 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""" @@ -590,6 +942,20 @@ def _start_decode_failure_recovery(self, error: AblyException) -> None: 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. @@ -603,7 +969,7 @@ class Channels(RestChannels): """ # RTS3 - def get(self, name: str, options: Optional[ChannelOptions] = None) -> RealtimeChannel: + def get(self, name: str, options: ChannelOptions | None = None, **kwargs) -> RealtimeChannel: """Creates a new RealtimeChannel object, or returns the existing channel object. Parameters @@ -613,7 +979,15 @@ def get(self, name: str, options: Optional[ChannelOptions] = None) -> RealtimeCh 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: @@ -666,7 +1040,7 @@ def _on_channel_message(self, msg: dict) -> None: channel._on_message(msg) - def _propagate_connection_interruption(self, state: ConnectionState, reason: Optional[AblyException]) -> None: + def _propagate_connection_interruption(self, state: ConnectionState, reason: AblyException | None) -> None: from_channel_states = ( ChannelState.ATTACHING, ChannelState.ATTACHED, diff --git a/ably/realtime/connection.py b/ably/realtime/connection.py index a27d0835..907f56a5 100644 --- a/ably/realtime/connection.py +++ b/ably/realtime/connection.py @@ -1,12 +1,15 @@ 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 -from typing import TYPE_CHECKING, Optional if TYPE_CHECKING: from ably.realtime.realtime import AblyRealtime @@ -39,7 +42,7 @@ class Connection(EventEmitter): # RTN4 def __init__(self, realtime: AblyRealtime): self.__realtime = realtime - self.__error_reason: Optional[AblyException] = None + 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 @@ -62,7 +65,7 @@ async def close(self) -> None: connection without an explicit call to connect() """ self.connection_manager.request_state(ConnectionState.CLOSING) - await self.once_async(ConnectionState.CLOSED) + await self._when_state(ConnectionState.CLOSED) # RTN13 async def ping(self) -> float: @@ -84,6 +87,13 @@ async def ping(self) -> float: """ 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 @@ -102,7 +112,7 @@ def state(self) -> ConnectionState: # RTN25 @property - def error_reason(self) -> Optional[AblyException]: + def error_reason(self) -> AblyException | None: """An object describing the last error which occurred on the channel, if any.""" return self.__error_reason @@ -115,5 +125,5 @@ def connection_manager(self) -> ConnectionManager: return self.__connection_manager @property - def connection_details(self) -> Optional[ConnectionDetails]: + def connection_details(self) -> ConnectionDetails | None: return self.__connection_manager.connection_details diff --git a/ably/realtime/connectionmanager.py b/ably/realtime/connectionmanager.py index eb49b2d6..8b51fb0f 100644 --- a/ably/realtime/connectionmanager.py +++ b/ably/realtime/connectionmanager.py @@ -1,19 +1,24 @@ from __future__ import annotations -import logging + 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.websockettransport import WebSocketTransport, ProtocolMessageAction + 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.exceptions import AblyException, IncompatibleClientIdException from ably.util.eventemitter import EventEmitter -from datetime import datetime -from ably.util.helper import get_random_id, Timer, is_token_error -from typing import Optional, TYPE_CHECKING -from ably.types.connectiondetails import ConnectionDetails -from queue import Queue +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 @@ -21,33 +26,140 @@ 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: Optional[asyncio.Future] = None + self.__ping_future: asyncio.Future | None = None self.__timeout_in_secs: float = self.options.realtime_request_timeout / 1000 - self.transport: Optional[WebSocketTransport] = None - self.__connection_details: Optional[ConnectionDetails] = None - self.connection_id: Optional[str] = None + 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: Optional[Timer] = None - self.suspend_timer: Optional[Timer] = None - self.retry_timer: Optional[Timer] = None - self.connect_base_task: Optional[asyncio.Task] = None - self.disconnect_transport_task: Optional[asyncio.Task] = None - self.__fallback_hosts: list[str] = self.options.get_fallback_realtime_hosts() - self.queued_messages: Queue = Queue() - self.__error_reason: Optional[AblyException] = None + 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: Optional[AblyException] = None) -> None: + 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: @@ -67,6 +179,13 @@ async def __get_transport_params(self) -> dict: 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: @@ -75,54 +194,149 @@ async def close_impl(self) -> None: self.cancel_suspend_timer() self.start_transition_timer(ConnectionState.CLOSING, fail_state=ConnectionState.CLOSED) if self.transport: - await self.transport.dispose() + # 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() - self.notify_state(ConnectionState.CLOSED) + # 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 - async def send_protocol_message(self, protocol_message: dict) -> None: - if self.state in ( - ConnectionState.DISCONNECTED, - ConnectionState.CONNECTING, - ): - self.queued_messages.put(protocol_message) - return + self.notify_state(ConnectionState.CLOSED) - if self.state == ConnectionState.CONNECTED: - if self.transport: - await self.transport.send(protocol_message) - else: - log.exception( - "ConnectionManager.send_protocol_message(): can not send message with no active transport" + 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 - raise AblyException(f"ConnectionManager.send_protocol_message(): called in {self.state}", 500, 50000) + 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) - def send_queued_messages(self) -> None: - log.info(f'ConnectionManager.send_queued_messages(): sending {self.queued_messages.qsize()} message(s)') - while not self.queued_messages.empty(): - asyncio.create_task(self.send_protocol_message(self.queued_messages.get())) + # 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 {self.queued_messages.qsize()} messages;" + + f"ConnectionManager.fail_queued_messages(): discarding {len(self.queued_messages)} messages;" + f" reason = {err}" ) - while not self.queued_messages.empty(): - msg = self.queued_messages.get() - log.exception(f"ConnectionManager.fail_queued_messages(): Failed to send protocol message: {msg}") + 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) + raise AblyException("Ping request cancelled due to request timeout", 504, 50003) from None return response self.__ping_future = asyncio.Future() @@ -136,16 +350,26 @@ async def ping(self) -> float: try: await asyncio.wait_for(self.__ping_future, self.__timeout_in_secs) except asyncio.TimeoutError: - raise AblyException("Timeout waiting for ping response", 504, 50003) + 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: Optional[AblyException] = None) -> None: + 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 @@ -233,7 +457,7 @@ async def on_closed(self) -> None: def on_channel_message(self, msg: dict) -> None: self.__ably.channels._on_channel_message(msg) - def on_heartbeat(self, id: Optional[str]) -> None: + def on_heartbeat(self, id: str | None) -> None: if self.__ping_future: # Resolve on heartbeat from ping request. if self.__ping_id == id: @@ -241,7 +465,39 @@ def on_heartbeat(self, id: Optional[str]) -> None: self.__ping_future.set_result(None) self.__ping_future = None - def deactivate_transport(self, reason: Optional[AblyException] = 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) @@ -275,7 +531,7 @@ def start_connect(self) -> None: 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) -> Optional[Exception]: + async def connect_with_fallback_hosts(self, fallback_hosts: list) -> Exception | None: for host in fallback_hosts: try: if self.check_connection(): @@ -295,7 +551,7 @@ async def connect_with_fallback_hosts(self, fallback_hosts: list) -> Optional[Ex async def connect_base(self) -> None: fallback_hosts = self.__fallback_hosts - primary_host = self.options.get_realtime_host() + primary_host = self.options.get_host() try: await self.try_host(primary_host) return @@ -343,8 +599,8 @@ async def on_transport_failed(exception): except asyncio.CancelledError: return - def notify_state(self, state: ConnectionState, reason: Optional[AblyException] = None, - retry_immediately: Optional[bool] = None) -> None: + 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) @@ -380,10 +636,18 @@ def notify_state(self, state: ConnectionState, reason: Optional[AblyException] = 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: Optional[ConnectionState] = None) -> None: + 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: @@ -428,7 +692,6 @@ def on_suspend_timer_expire() -> None: AblyException("Connection to server unavailable", 400, 80002) ) self.__fail_state = ConnectionState.SUSPENDED - self.__connection_details = None self.suspend_timer = Timer(Defaults.connection_state_ttl, on_suspend_timer_expire) @@ -463,6 +726,8 @@ def cancel_retry_timer(self) -> 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): @@ -520,5 +785,5 @@ def state(self) -> ConnectionState: return self.__state @property - def connection_details(self) -> Optional[ConnectionDetails]: + 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 index ea454df1..ab435304 100644 --- a/ably/realtime/realtime.py +++ b/ably/realtime/realtime.py @@ -1,11 +1,11 @@ -import logging import asyncio +import logging from typing import Optional -from ably.realtime.realtime_channel import Channels + +from ably.realtime.channel import Channels from ably.realtime.connection import Connection, ConnectionState from ably.rest.rest import AblyRest - log = logging.getLogger(__name__) @@ -48,10 +48,14 @@ def __init__(self, key: Optional[str] = None, loop: Optional[asyncio.AbstractEve 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 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 a48cc162..d2057533 100644 --- a/ably/rest/auth.py +++ b/ably/rest/auth.py @@ -5,15 +5,15 @@ import time import uuid from datetime import timedelta -from typing import Optional, TYPE_CHECKING, Union +from typing import TYPE_CHECKING import httpx from ably.types.options import Options if TYPE_CHECKING: - from ably.rest.rest import AblyRest from ably.realtime.realtime import AblyRealtime + from ably.rest.rest import AblyRest from ably.types.capability import Capability from ably.types.tokendetails import TokenDetails @@ -31,7 +31,7 @@ class Method: BASIC = "BASIC" TOKEN = "TOKEN" - def __init__(self, ably: Union[AblyRest, AblyRealtime], options: Options): + def __init__(self, ably: AblyRest | AblyRealtime, options: Options): self.__ably = ably self.__auth_options = options @@ -43,10 +43,10 @@ def __init__(self, ably: Union[AblyRest, AblyRealtime], options: Options): self.__client_id = None self.__client_id_validated: bool = False - self.__basic_credentials: Optional[str] = None - self.__auth_params: Optional[dict] = None - self.__token_details: Optional[TokenDetails] = None - self.__time_offset: Optional[int] = 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 @@ -56,7 +56,7 @@ def __init__(self, ably: Union[AblyRest, AblyRealtime], options: Options): # 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 @@ -89,8 +89,8 @@ def __init__(self, ably: Union[AblyRest, AblyRealtime], options: Options): async def get_auth_transport_param(self): auth_credentials = {} - if self.auth_options.client_id: - auth_credentials["client_id"] = self.auth_options.client_id + 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 @@ -151,14 +151,14 @@ def token_details_has_expired(self): return expires < timestamp + token_details.TOKEN_EXPIRY_BUFFER - async def authorize(self, token_params: Optional[dict] = None, auth_options=None): + async def authorize(self, token_params: dict | None = None, auth_options=None): return await self.__authorize_when_necessary(token_params, auth_options, force=True) - async def request_token(self, token_params: Optional[dict] = None, + async def request_token(self, token_params: dict | None = None, # auth_options - key_name: Optional[str] = None, key_secret: Optional[str] = None, auth_callback=None, - auth_url: Optional[str] = None, auth_method: Optional[str] = None, - auth_headers: Optional[dict] = None, auth_params: Optional[dict] = None, + 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, @@ -166,8 +166,8 @@ async def request_token(self, token_params: Optional[dict] = None, 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) @@ -180,13 +180,13 @@ async def request_token(self, token_params: Optional[dict] = 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") try: token_request = await auth_callback(token_params) except Exception as e: - raise AblyException("auth_callback raised an exception", 401, 40170, cause=e) + raise AblyException("auth_callback raised an exception", 401, 40170, cause=e) from e elif auth_url: log.debug("using token auth with authUrl") @@ -210,7 +210,7 @@ async def request_token(self, token_params: Optional[dict] = None, 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) + 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) @@ -218,7 +218,7 @@ async def request_token(self, token_params: Optional[dict] = None, 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 = await self.ably.http.post( token_path, @@ -229,11 +229,11 @@ async def request_token(self, token_params: Optional[dict] = 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) - async def create_token_request(self, token_params: Optional[dict | str] = None, key_name: Optional[str] = None, - key_secret: Optional[str] = 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 = {} @@ -349,7 +349,7 @@ def _configure_client_id(self, new_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, 40102) + f"Client ID cannot be changed to '{new_client_id}'", 400, 40102) self.__client_id_validated = True self.__client_id = new_client_id @@ -369,16 +369,16 @@ async def _get_auth_headers(self): # RSA7e2 if self.client_id: return { - 'Authorization': 'Basic %s' % self.basic_credentials, + 'Authorization': f'Basic {self.basic_credentials}', 'X-Ably-ClientId': base64.b64encode(self.client_id.encode('utf-8')) } return { - 'Authorization': 'Basic %s' % self.basic_credentials, + 'Authorization': f'Basic {self.basic_credentials}', } else: await self.__authorize_when_necessary() return { - 'Authorization': 'Bearer %s' % self.token_credentials, + 'Authorization': f'Bearer {self.token_credentials}', } def _timestamp(self): diff --git a/ably/rest/channel.py b/ably/rest/channel.py index d7995607..f6b118b7 100644 --- a/ably/rest/channel.py +++ b/ably/rest/channel.py @@ -1,32 +1,46 @@ import base64 -from collections import OrderedDict -import logging import json +import logging import os -from typing import Iterator +from collections import OrderedDict +from typing import Iterator, Optional from urllib import parse -from methoddispatch import SingleDispatch, singledispatch import msgpack from ably.http.paginatedresult import PaginatedResult, format_params +from ably.rest.annotations import RestAnnotations from ably.types.channeldetails import ChannelDetails -from ably.types.message import Message, make_message_response_handler +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 async def history(self, direction=None, limit: int = None, start=None, end=None): @@ -48,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: @@ -58,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: @@ -76,15 +90,19 @@ 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) async def publish_message(self, message, params=None, timeout=None): return await self.publish_messages([message], params, timeout=timeout) - @_publish.register(list) 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: @@ -94,11 +112,16 @@ async 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) - return await self.ably.http.post(path, body=request_body, timeout=timeout) + response = await self.ably.http.post(path, body=request_body, timeout=timeout) + + # 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() - @_publish.register(str) async def publish_name_data(self, name, data, timeout=None): messages = [Message(name, data)] return await self.publish_messages(messages, timeout=timeout) @@ -134,11 +157,192 @@ async def publish(self, *args, **kwargs): async def status(self): """Retrieves current channel active status with no. of publishers, subscribers, presence_members etc""" - path = '/channels/%s' % self.name + 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, + ) + + # 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) + + async def get_message_versions(self, serial_or_message, params=None): + """Retrieves version history for a message. + + Parameters: + - serial_or_message: Either a string serial or a Message object with a serial field. + - params: Optional dict of query parameters for pagination (e.g., limit, start, end, direction). + + Returns: + - PaginatedResult containing Message objects representing each version. + + Raises: + - AblyException: If the serial is missing or versions cannot be retrieved. + """ + # Extract serial from string or Message object + if isinstance(serial_or_message, str): + serial = serial_or_message + elif isinstance(serial_or_message, Message): + serial = serial_or_message.serial + else: + serial = None + + if not serial: + raise AblyException( + 'This message lacks a serial. Make sure you have enabled "Message annotations, ' + 'updates, and deletes" in channel settings on your dashboard.', + status_code=400, + code=40003, + ) + + # Build the path + params_str = format_params({}, **params) if params else '' + path = self.__base_path + 'messages/' + parse.quote_plus(serial, safe=':') + '/versions' + params_str + + # Create message handler for decoding + message_handler = make_message_response_handler(self.__cipher) + + # Return paginated result + return await PaginatedResult.paginated_query( + self.ably.http, + url=path, + response_processor=message_handler + ) + @property def ably(self): return self.__ably @@ -163,6 +367,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 diff --git a/ably/rest/push.py b/ably/rest/push.py index d3cf0e03..f99b2b1d 100644 --- a/ably/rest/push.py +++ b/ably/rest/push.py @@ -1,8 +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: @@ -43,10 +47,10 @@ async def publish(self, recipient: dict, data: dict, timeout: Optional[float] = - `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(data)) + raise TypeError(f'Unexpected {type(data)} data, expected a dict') if not recipient: raise ValueError('recipient is empty') @@ -75,7 +79,7 @@ async def get(self, device_id: str): :Parameters: - `device_id`: the id of the device """ - path = '/push/deviceRegistrations/%s' % device_id + path = f'/push/deviceRegistrations/{device_id}' response = await self.ably.http.get(path) obj = response.to_native() return DeviceDetails.from_dict(obj) @@ -99,7 +103,7 @@ async def save(self, device: dict): - `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 = await self.ably.http.put(path, body=body) obj = response.to_native() @@ -111,7 +115,7 @@ async def remove(self, device_id: str): :Parameters: - `device_id`: the id of the device """ - path = '/push/deviceRegistrations/%s' % device_id + path = f'/push/deviceRegistrations/{device_id}' return await self.ably.http.delete(path) async def remove_where(self, **params): diff --git a/ably/rest/rest.py b/ably/rest/rest.py index a42ba2fd..bc84e638 100644 --- a/ably/rest/rest.py +++ b/ably/rest/rest.py @@ -3,15 +3,14 @@ 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__) @@ -33,8 +32,14 @@ def __init__(self, key: Optional[str] = None, token: Optional[str] = None, **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,9 +67,7 @@ def __init__(self, key: Optional[str] = None, token: Optional[str] = None, else: options = Options(**kwargs) - try: - self._is_realtime - except AttributeError: + if not hasattr(self, '_is_realtime'): self._is_realtime = False self.__http = Http(self, options) diff --git a/ably/scripts/unasync.py b/ably/scripts/unasync.py index 72126f41..d13e20f2 100644 --- a/ably/scripts/unasync.py +++ b/ably/scripts/unasync.py @@ -72,7 +72,7 @@ def _unasync_file(self, filepath): with open(filepath, "rb") as f: encoding, _ = std_tokenize.detect_encoding(f.readline) - with open(filepath, "rt", encoding=encoding) as f: + 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) diff --git a/ably/transport/defaults.py b/ably/transport/defaults.py index 7a732d9a..40d73e08 100644 --- a/ably/transport/defaults.py +++ b/ably/transport/defaults.py @@ -1,17 +1,8 @@ class Defaults: - protocol_version = "2" - 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" # RTN2 + protocol_version = "5" + connectivity_check_url = "https://internet-up.ably-realtime.com/is-the-internet-up.txt" - environment = 'production' + endpoint = 'main' port = 80 tls_port = 443 @@ -53,11 +44,34 @@ def get_scheme(options): return "http" @staticmethod - def get_environment_fallback_hosts(environment): + 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 [ - environment + "-a-fallback.ably-realtime.com", - environment + "-b-fallback.ably-realtime.com", - environment + "-c-fallback.ably-realtime.com", - environment + "-d-fallback.ably-realtime.com", - environment + "-e-fallback.ably-realtime.com", + 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 index d3f39529..be13d096 100644 --- a/ably/transport/websockettransport.py +++ b/ably/transport/websockettransport.py @@ -1,22 +1,30 @@ from __future__ import annotations -from typing import TYPE_CHECKING + import asyncio -from enum import IntEnum 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, connect as ws_connect + 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, connect as ws_connect + from websockets.client import WebSocketClientProtocol + from websockets.client import connect as ws_connect from websockets.exceptions import ConnectionClosedOK, WebSocketException @@ -28,7 +36,11 @@ class ProtocolMessageAction(IntEnum): HEARTBEAT = 0 + ACK = 1 + NACK = 2 + CONNECT = 3 CONNECTED = 4 + DISCONNECT = 5 DISCONNECTED = 6 CLOSE = 7 CLOSED = 8 @@ -37,8 +49,14 @@ class ProtocolMessageAction(IntEnum): 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): @@ -56,6 +74,7 @@ def __init__(self, connection_manager: ConnectionManager, host: str, params: dic self.is_disposed = False self.host = host self.params = params + self.format = params.get('format', 'json') super().__init__() def connect(self): @@ -91,7 +110,7 @@ async def ws_connect(self, ws_url, headers): 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 + raise exception from e async def _handle_websocket_connection(self, ws_url, websocket): log.info(f'ws_connect(): connection established to {ws_url}') @@ -124,8 +143,8 @@ async def on_protocol_message(self, msg): 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_realtime_host(): # RTN17e - self.options.fallback_realtime_host = self.host + 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') @@ -150,10 +169,28 @@ async def on_protocol_message(self, msg): 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.MESSAGE, + ProtocolMessageAction.PRESENCE, + ProtocolMessageAction.ANNOTATION, + ProtocolMessageAction.SYNC ): self.connection_manager.on_channel_message(msg) @@ -162,12 +199,25 @@ async def ws_read_loop(self): raise AblyException('ws_read_loop started with no websocket', 500, 50000) try: async for raw in self.websocket: - msg = json.loads(raw) - task = asyncio.create_task(self.on_protocol_message(msg)) - task.add_done_callback(self.on_protcol_message_handled) - except ConnectionClosedOK: + # 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() @@ -186,26 +236,51 @@ def on_read_loop_done(self, task: asyncio.Task): 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() - raw_msg = json.dumps(message) - log.info(f'WebSocketTransport.send(): sending {raw_msg}') + # 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): 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 0c35940e..4f931466 100644 --- a/ably/types/capability.py +++ b/ably/types/capability.py @@ -1,8 +1,7 @@ -from collections.abc import MutableMapping -from typing import Optional, Union import json import logging - +from collections.abc import MutableMapping +from typing import Optional, Union log = logging.getLogger(__name__) 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 index 914b5956..dcb68d67 100644 --- a/ably/types/channelstate.py +++ b/ably/types/channelstate.py @@ -1,6 +1,7 @@ from dataclasses import dataclass -from typing import Optional from enum import Enum +from typing import Optional + from ably.util.exceptions import AblyException diff --git a/ably/types/connectionstate.py b/ably/types/connectionstate.py index 3a7fb111..ec958358 100644 --- a/ably/types/connectionstate.py +++ b/ably/types/connectionstate.py @@ -1,5 +1,5 @@ -from enum import Enum from dataclasses import dataclass +from enum import Enum from typing import Optional from ably.util.exceptions import AblyException diff --git a/ably/types/device.py b/ably/types/device.py index 337de002..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'} @@ -17,13 +16,13 @@ def __init__(self, id, client_id=None, form_factor=None, metadata=None, 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 diff --git a/ably/types/flags.py b/ably/types/flags.py index 1666434c..86666019 100644 --- a/ably/types/flags.py +++ b/ably/types/flags.py @@ -13,6 +13,8 @@ class Flag(int, Enum): 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): diff --git a/ably/types/message.py b/ably/types/message.py index 13fa3c12..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, DeltaExtras 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,48 +265,21 @@ 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} @@ -187,6 +295,9 @@ def from_encoded(obj, cipher=None, context=None): timestamp = obj.get('timestamp') encoding = obj.get('encoding', '') extras = obj.get('extras', None) + serial = obj.get('serial') + action = obj.get('action') + version = obj.get('version', None) delta_extra = DeltaExtras(extras) if delta_extra.from_id and delta_extra.from_id != context.last_message_id: @@ -195,6 +306,46 @@ def from_encoded(obj, cipher=None, context=None): decoded_data = Message.decode(data, encoding, cipher, context) + if action is not None: + try: + action = MessageAction(action) + except ValueError: + # If it's not a valid action value, store as None + action = None + else: + action = None + + if version is not None: + version = MessageVersion.from_dict(version) + else: + # TM2s + version = MessageVersion(serial=serial, timestamp=timestamp) + + # Parse annotations from the wire format + annotations_obj = obj.get('annotations') + if annotations_obj is None: + # TM2u: Always initialize annotations with empty summary + annotations = MessageAnnotations() + else: + annotations = MessageAnnotations.from_dict(annotations_obj) + + # Process annotation summary entries to ensure clipped fields are set + if annotations and annotations.summary: + for annotation_type, summary_entry in annotations.summary.items(): + # TM7c1c, TM7d1c: For distinct.v1, unique.v1, multiple.v1 + if (annotation_type.endswith(':distinct.v1') or + annotation_type.endswith(':unique.v1') or + annotation_type.endswith(':multiple.v1')): + # These types have entries that need clipped field + if isinstance(summary_entry, dict): + for _entry_key, entry_value in summary_entry.items(): + if isinstance(entry_value, dict) and 'clipped' not in entry_value: + entry_value['clipped'] = False + # TM7c1c: For flag.v1 + elif annotation_type.endswith(':flag.v1'): + if isinstance(summary_entry, dict) and 'clipped' not in summary_entry: + summary_entry['clipped'] = False + return Message( id=id, name=name, @@ -202,6 +353,10 @@ def from_encoded(obj, cipher=None, context=None): client_id=client_id, timestamp=timestamp, extras=extras, + serial=serial, + action=action, + version=version, + annotations=annotations, **decoded_data ) @@ -236,3 +391,9 @@ 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 31b59f84..2d2b6041 100644 --- a/ably/types/mixins.py +++ b/ably/types/mixins.py @@ -5,7 +5,6 @@ from ably.util.crypto import CipherData from ably.util.exceptions import AblyException - log = logging.getLogger(__name__) ENC_VCDIFF = "vcdiff" @@ -102,9 +101,9 @@ def decode(data, encoding='', cipher=None, context=None): except Exception as e: log.error(f'VCDiff decode failed: {e}') - raise AblyException('VCDiff decode failure', 40018, 40018) + raise AblyException('VCDiff decode failure', 40018, 40018) from e - elif encoding.startswith('%s+' % CipherData.ENCODING_ID): + 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') @@ -117,7 +116,7 @@ def decode(data, encoding='', cipher=None, context=None): pass else: log.error('Message cannot be decoded. ' - "Unsupported encoding type: '%s'" % encoding) + f"Unsupported encoding type: '{encoding}'") encoding_list.append(encoding) break 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 823b1ae7..1dad41fb 100644 --- a/ably/types/options.py +++ b/ably/types/options.py @@ -1,9 +1,10 @@ -import random 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__) @@ -26,16 +27,26 @@ def decode(self, delta: bytes, base: bytes) -> bytes: 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, 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, + 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, **kwargs): + 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 @@ -55,34 +66,56 @@ def __init__(self, client_id=None, log_level=0, tls=True, rest_host=None, realti 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 ValueError('specify realtime_host or environment, not both') + 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 None: - environment = Defaults.environment + 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_retry_timeout = fallback_retry_timeout self.__disconnected_retry_timeout = disconnected_retry_timeout @@ -93,12 +126,10 @@ def __init__(self, client_id=None, log_level=0, tls=True, rest_host=None, realti self.__connection_state_ttl = connection_state_ttl self.__suspended_retry_timeout = suspended_retry_timeout self.__connectivity_check_url = connectivity_check_url - self.__fallback_realtime_host = None self.__add_request_ids = add_request_ids self.__vcdiff_decoder = vcdiff_decoder - - self.__rest_hosts = self.__get_rest_hosts() - self.__realtime_hosts = self.__get_realtime_hosts() + self.__transport_params = transport_params or {} + self.__hosts = self.__get_hosts() @property def client_id(self): @@ -124,23 +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 - - # RTC1d - @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 @@ -182,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): @@ -267,12 +281,18 @@ def connectivity_check_url(self): return self.__connectivity_check_url @property - def fallback_realtime_host(self): - return self.__fallback_realtime_host + def fallback_host(self): + """ + For internal use only, can be deleted in future + """ + return self.__fallback_host - @fallback_realtime_host.setter - def fallback_realtime_host(self, value): - self.__fallback_realtime_host = value + @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): @@ -282,37 +302,24 @@ def add_request_ids(self): def vcdiff_decoder(self): return self.__vcdiff_decoder - def __get_rest_hosts(self): + @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 + 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: - fallback_hosts = Defaults.fallback_hosts - elif environment != 'production': - fallback_hosts = Defaults.get_environment_fallback_hosts(environment) - else: - fallback_hosts = [] - # Shuffle fallback_hosts = list(fallback_hosts) random.shuffle(fallback_hosts) @@ -323,31 +330,19 @@ def __get_rest_hosts(self): hosts = hosts[:http_max_retry_count] return hosts - def __get_realtime_hosts(self): - if self.realtime_host is not None: - host = self.realtime_host - return [host] - elif self.environment != "production": - host = f'{self.environment}-{Defaults.realtime_host}' - else: - host = Defaults.realtime_host - - return [host] + self.__fallback_hosts - - def get_rest_hosts(self): - return self.__rest_hosts - - def get_rest_host(self): - return self.__rest_hosts[0] - - def get_realtime_hosts(self): - return self.__realtime_hosts + def get_hosts(self): + return self.__hosts - def get_realtime_host(self): - return self.__realtime_hosts[0] + def get_host(self): + return self.__hosts[0] - def get_fallback_rest_hosts(self): - return self.__rest_hosts[1:] + # 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_realtime_hosts(self): - return self.__realtime_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 6c4f4ca6..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,12 +79,95 @@ 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, context=None): id = obj.get('id') @@ -112,10 +195,17 @@ def from_encoded(obj, cipher=None, context=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 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 56adcd88..656f8947 100644 --- a/ably/types/typedbuffer.py +++ b/ably/types/typedbuffer.py @@ -74,7 +74,7 @@ def from_obj(obj): 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): data_type = DataType.DOUBLE buffer = struct.pack('>d', obj) @@ -85,7 +85,7 @@ def from_obj(obj): 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, data_type) @@ -101,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 3b18c49e..1cfff585 100644 --- a/ably/util/case.py +++ b/ably/util/case.py @@ -1,6 +1,5 @@ import re - first_cap_re = re.compile('(.)([A-Z][a-z]+)') all_cap_re = re.compile('([a-z0-9])([A-Z])') diff --git a/ably/util/crypto.py b/ably/util/crypto.py index acd558b6..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): @@ -153,7 +152,7 @@ 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) @@ -175,5 +174,5 @@ def validate_cipher_params(cipher_params): 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 index 4d2bfb41..74f0beb6 100644 --- a/ably/util/eventemitter.py +++ b/ably/util/eventemitter.py @@ -1,5 +1,6 @@ import asyncio import logging + from pyee.asyncio import AsyncIOEventEmitter from ably.util.helper import is_callable_or_coroutine diff --git a/ably/util/exceptions.py b/ably/util/exceptions.py index 6ec73bf0..a8bbae39 100644 --- a/ably/util/exceptions.py +++ b/ably/util/exceptions.py @@ -1,7 +1,7 @@ import functools import logging -import msgpack +import msgpack log = logging.getLogger(__name__) @@ -20,9 +20,9 @@ def __init__(self, message, status_code, code, cause=None): self.cause = cause def __str__(self): - str = '%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 += ' (cause: %s)' % self.cause + str += f' (cause: {self.cause})' return str @property @@ -43,7 +43,7 @@ def raise_for_response(response): 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 decoded_response and 'error' in decoded_response: error = decoded_response['error'] @@ -56,7 +56,7 @@ 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, @@ -77,7 +77,13 @@ def decode_error_response(response): 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): @@ -91,7 +97,7 @@ async def wrapper(*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 index 76ff9e2d..a35ebe6e 100644 --- a/ably/util/helper.py +++ b/ably/util/helper.py @@ -1,16 +1,21 @@ +import asyncio import inspect +import json import random import string -import asyncio import time -from typing import Callable, Tuple, Dict -from urllib.parse import urlparse, parse_qs +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))) + random_id = ''.join(random.choice(source) for i in range(8)) return random_id @@ -69,3 +74,37 @@ async def _job(self): 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/vcdiff/default_vcdiff_decoder.py b/ably/vcdiff/defaultvcdiffdecoder.py similarity index 100% rename from ably/vcdiff/default_vcdiff_decoder.py rename to ably/vcdiff/defaultvcdiffdecoder.py 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/poetry.lock b/poetry.lock deleted file mode 100644 index 237d3530..00000000 --- a/poetry.lock +++ /dev/null @@ -1,1250 +0,0 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. - -[[package]] -name = "anyio" -version = "3.7.1" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.7\"" -files = [ - {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, - {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, -] - -[package.dependencies] -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} - -[package.extras] -doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] -test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4) ; python_version < \"3.8\"", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17) ; python_version < \"3.12\" and platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] -trio = ["trio (<0.22)"] - -[[package]] -name = "anyio" -version = "4.5.2" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version >= \"3.8\"" -files = [ - {file = "anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f"}, - {file = "anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b"}, -] - -[package.dependencies] -exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} - -[package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21.0b1) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\""] -trio = ["trio (>=0.26.1)"] - -[[package]] -name = "async-case" -version = "10.1.0" -description = "Backport of Python 3.8's unittest.async_case" -optional = false -python-versions = "*" -groups = ["dev"] -markers = "python_version == \"3.7\"" -files = [ - {file = "async_case-10.1.0.tar.gz", hash = "sha256:b819f68c78f6c640ab1101ecf69fac189402b490901fa2abc314c48edab5d3da"}, -] - -[[package]] -name = "certifi" -version = "2025.8.3" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, - {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, -] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\"" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.2.7" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "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"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "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"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, - {file = "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"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, - {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, - {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, - {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, - {file = "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"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, - {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, - {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, - {file = "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"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, - {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, - {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "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"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, -] - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version < \"3.11\"" -files = [ - {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, - {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "execnet" -version = "2.0.2" -description = "execnet: rapid multi-Python deployment" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "execnet-2.0.2-py3-none-any.whl", hash = "sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41"}, - {file = "execnet-2.0.2.tar.gz", hash = "sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af"}, -] - -[package.extras] -testing = ["hatch", "pre-commit", "pytest", "tox"] - -[[package]] -name = "flake8" -version = "3.9.2" -description = "the modular source code checker: pep8 pyflakes and co" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -groups = ["dev"] -files = [ - {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, - {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, -] - -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.7.0,<2.8.0" -pyflakes = ">=2.3.0,<2.4.0" - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.7\"" -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[package.dependencies] -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} - -[[package]] -name = "h11" -version = "0.16.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version >= \"3.8\"" -files = [ - {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, - {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, -] - -[[package]] -name = "h2" -version = "4.1.0" -description = "HTTP/2 State-Machine based protocol implementation" -optional = false -python-versions = ">=3.6.1" -groups = ["main"] -files = [ - {file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"}, - {file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"}, -] - -[package.dependencies] -hpack = ">=4.0,<5" -hyperframe = ">=6.0,<7" - -[[package]] -name = "hpack" -version = "4.0.0" -description = "Pure-Python HPACK header compression" -optional = false -python-versions = ">=3.6.1" -groups = ["main"] -files = [ - {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, - {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, -] - -[[package]] -name = "httpcore" -version = "0.17.3" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.7\"" -files = [ - {file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"}, - {file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"}, -] - -[package.dependencies] -anyio = ">=3.0,<5.0" -certifi = "*" -h11 = ">=0.13,<0.15" -sniffio = "==1.*" - -[package.extras] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] - -[[package]] -name = "httpcore" -version = "1.0.9" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version >= \"3.8\"" -files = [ - {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, - {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.16" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] - -[[package]] -name = "httpx" -version = "0.24.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.7\"" -files = [ - {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"}, - {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"}, -] - -[package.dependencies] -certifi = "*" -httpcore = ">=0.15.0,<0.18.0" -idna = "*" -sniffio = "*" - -[package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] - -[[package]] -name = "httpx" -version = "0.28.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -groups = ["main", "dev"] -markers = "python_version >= \"3.8\"" -files = [ - {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, - {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "==1.*" -idna = "*" - -[package.extras] -brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "hyperframe" -version = "6.0.1" -description = "HTTP/2 framing layer for Python" -optional = false -python-versions = ">=3.6.1" -groups = ["main"] -files = [ - {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"}, - {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, -] - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -groups = ["main", "dev"] -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "4.13.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"}, - {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"}, -] - -[package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] -perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "mccabe" -version = "0.6.1" -description = "McCabe checker, plugin for flake8" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, - {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, -] - -[[package]] -name = "methoddispatch" -version = "5.0.1" -description = "singledispatch decorator for class methods." -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "methoddispatch-5.0.1-py3-none-any.whl", hash = "sha256:1c43e16f4e18b31b45193f63cb2b99515f35b4ba3ad9a16611be6532cea171a1"}, - {file = "methoddispatch-5.0.1.tar.gz", hash = "sha256:b88b7f40665515c43101b0a9c55323b6771eab71d6ebbb5dac94d35625f1be4c"}, -] - -[[package]] -name = "mock" -version = "4.0.3" -description = "Rolling backport of unittest.mock for all Pythons" -optional = false -python-versions = ">=3.6" -groups = ["dev"] -files = [ - {file = "mock-4.0.3-py3-none-any.whl", hash = "sha256:122fcb64ee37cfad5b3f48d7a7d51875d7031aaf3d8be7c42e2bee25044eee62"}, - {file = "mock-4.0.3.tar.gz", hash = "sha256:7d3fbbde18228f4ff2f1f119a45cdffa458b4c0dee32eb4d2bb2f82554bac7bc"}, -] - -[package.extras] -build = ["blurb", "twine", "wheel"] -docs = ["sphinx"] -test = ["pytest (<5.4)", "pytest-cov"] - -[[package]] -name = "msgpack" -version = "1.0.5" -description = "MessagePack serializer" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "msgpack-1.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:525228efd79bb831cf6830a732e2e80bc1b05436b086d4264814b4b2955b2fa9"}, - {file = "msgpack-1.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f8d8b3bf1ff2672567d6b5c725a1b347fe838b912772aa8ae2bf70338d5a198"}, - {file = "msgpack-1.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdc793c50be3f01106245a61b739328f7dccc2c648b501e237f0699fe1395b81"}, - {file = "msgpack-1.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cb47c21a8a65b165ce29f2bec852790cbc04936f502966768e4aae9fa763cb7"}, - {file = "msgpack-1.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e42b9594cc3bf4d838d67d6ed62b9e59e201862a25e9a157019e171fbe672dd3"}, - {file = "msgpack-1.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:55b56a24893105dc52c1253649b60f475f36b3aa0fc66115bffafb624d7cb30b"}, - {file = "msgpack-1.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:1967f6129fc50a43bfe0951c35acbb729be89a55d849fab7686004da85103f1c"}, - {file = "msgpack-1.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20a97bf595a232c3ee6d57ddaadd5453d174a52594bf9c21d10407e2a2d9b3bd"}, - {file = "msgpack-1.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d25dd59bbbbb996eacf7be6b4ad082ed7eacc4e8f3d2df1ba43822da9bfa122a"}, - {file = "msgpack-1.0.5-cp310-cp310-win32.whl", hash = "sha256:382b2c77589331f2cb80b67cc058c00f225e19827dbc818d700f61513ab47bea"}, - {file = "msgpack-1.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:4867aa2df9e2a5fa5f76d7d5565d25ec76e84c106b55509e78c1ede0f152659a"}, - {file = "msgpack-1.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9f5ae84c5c8a857ec44dc180a8b0cc08238e021f57abdf51a8182e915e6299f0"}, - {file = "msgpack-1.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e6ca5d5699bcd89ae605c150aee83b5321f2115695e741b99618f4856c50898"}, - {file = "msgpack-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5494ea30d517a3576749cad32fa27f7585c65f5f38309c88c6d137877fa28a5a"}, - {file = "msgpack-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ab2f3331cb1b54165976a9d976cb251a83183631c88076613c6c780f0d6e45a"}, - {file = "msgpack-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28592e20bbb1620848256ebc105fc420436af59515793ed27d5c77a217477705"}, - {file = "msgpack-1.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe5c63197c55bce6385d9aee16c4d0641684628f63ace85f73571e65ad1c1e8d"}, - {file = "msgpack-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed40e926fa2f297e8a653c954b732f125ef97bdd4c889f243182299de27e2aa9"}, - {file = "msgpack-1.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b2de4c1c0538dcb7010902a2b97f4e00fc4ddf2c8cda9749af0e594d3b7fa3d7"}, - {file = "msgpack-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bf22a83f973b50f9d38e55c6aade04c41ddda19b00c4ebc558930d78eecc64ed"}, - {file = "msgpack-1.0.5-cp311-cp311-win32.whl", hash = "sha256:c396e2cc213d12ce017b686e0f53497f94f8ba2b24799c25d913d46c08ec422c"}, - {file = "msgpack-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c4c68d87497f66f96d50142a2b73b97972130d93677ce930718f68828b382e2"}, - {file = "msgpack-1.0.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a2b031c2e9b9af485d5e3c4520f4220d74f4d222a5b8dc8c1a3ab9448ca79c57"}, - {file = "msgpack-1.0.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f837b93669ce4336e24d08286c38761132bc7ab29782727f8557e1eb21b2080"}, - {file = "msgpack-1.0.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1d46dfe3832660f53b13b925d4e0fa1432b00f5f7210eb3ad3bb9a13c6204a6"}, - {file = "msgpack-1.0.5-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:366c9a7b9057e1547f4ad51d8facad8b406bab69c7d72c0eb6f529cf76d4b85f"}, - {file = "msgpack-1.0.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:4c075728a1095efd0634a7dccb06204919a2f67d1893b6aa8e00497258bf926c"}, - {file = "msgpack-1.0.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:f933bbda5a3ee63b8834179096923b094b76f0c7a73c1cfe8f07ad608c58844b"}, - {file = "msgpack-1.0.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:36961b0568c36027c76e2ae3ca1132e35123dcec0706c4b7992683cc26c1320c"}, - {file = "msgpack-1.0.5-cp36-cp36m-win32.whl", hash = "sha256:b5ef2f015b95f912c2fcab19c36814963b5463f1fb9049846994b007962743e9"}, - {file = "msgpack-1.0.5-cp36-cp36m-win_amd64.whl", hash = "sha256:288e32b47e67f7b171f86b030e527e302c91bd3f40fd9033483f2cacc37f327a"}, - {file = "msgpack-1.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:137850656634abddfb88236008339fdaba3178f4751b28f270d2ebe77a563b6c"}, - {file = "msgpack-1.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c05a4a96585525916b109bb85f8cb6511db1c6f5b9d9cbcbc940dc6b4be944b"}, - {file = "msgpack-1.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56a62ec00b636583e5cb6ad313bbed36bb7ead5fa3a3e38938503142c72cba4f"}, - {file = "msgpack-1.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef8108f8dedf204bb7b42994abf93882da1159728a2d4c5e82012edd92c9da9f"}, - {file = "msgpack-1.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1835c84d65f46900920b3708f5ba829fb19b1096c1800ad60bae8418652a951d"}, - {file = "msgpack-1.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:e57916ef1bd0fee4f21c4600e9d1da352d8816b52a599c46460e93a6e9f17086"}, - {file = "msgpack-1.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:17358523b85973e5f242ad74aa4712b7ee560715562554aa2134d96e7aa4cbbf"}, - {file = "msgpack-1.0.5-cp37-cp37m-win32.whl", hash = "sha256:cb5aaa8c17760909ec6cb15e744c3ebc2ca8918e727216e79607b7bbce9c8f77"}, - {file = "msgpack-1.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:ab31e908d8424d55601ad7075e471b7d0140d4d3dd3272daf39c5c19d936bd82"}, - {file = "msgpack-1.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b72d0698f86e8d9ddf9442bdedec15b71df3598199ba33322d9711a19f08145c"}, - {file = "msgpack-1.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:379026812e49258016dd84ad79ac8446922234d498058ae1d415f04b522d5b2d"}, - {file = "msgpack-1.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:332360ff25469c346a1c5e47cbe2a725517919892eda5cfaffe6046656f0b7bb"}, - {file = "msgpack-1.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:476a8fe8fae289fdf273d6d2a6cb6e35b5a58541693e8f9f019bfe990a51e4ba"}, - {file = "msgpack-1.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9985b214f33311df47e274eb788a5893a761d025e2b92c723ba4c63936b69b1"}, - {file = "msgpack-1.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48296af57cdb1d885843afd73c4656be5c76c0c6328db3440c9601a98f303d87"}, - {file = "msgpack-1.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:addab7e2e1fcc04bd08e4eb631c2a90960c340e40dfc4a5e24d2ff0d5a3b3edb"}, - {file = "msgpack-1.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:916723458c25dfb77ff07f4c66aed34e47503b2eb3188b3adbec8d8aa6e00f48"}, - {file = "msgpack-1.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:821c7e677cc6acf0fd3f7ac664c98803827ae6de594a9f99563e48c5a2f27eb0"}, - {file = "msgpack-1.0.5-cp38-cp38-win32.whl", hash = "sha256:1c0f7c47f0087ffda62961d425e4407961a7ffd2aa004c81b9c07d9269512f6e"}, - {file = "msgpack-1.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:bae7de2026cbfe3782c8b78b0db9cbfc5455e079f1937cb0ab8d133496ac55e1"}, - {file = "msgpack-1.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:20c784e66b613c7f16f632e7b5e8a1651aa5702463d61394671ba07b2fc9e025"}, - {file = "msgpack-1.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:266fa4202c0eb94d26822d9bfd7af25d1e2c088927fe8de9033d929dd5ba24c5"}, - {file = "msgpack-1.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:18334484eafc2b1aa47a6d42427da7fa8f2ab3d60b674120bce7a895a0a85bdd"}, - {file = "msgpack-1.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57e1f3528bd95cc44684beda696f74d3aaa8a5e58c816214b9046512240ef437"}, - {file = "msgpack-1.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586d0d636f9a628ddc6a17bfd45aa5b5efaf1606d2b60fa5d87b8986326e933f"}, - {file = "msgpack-1.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a740fa0e4087a734455f0fc3abf5e746004c9da72fbd541e9b113013c8dc3282"}, - {file = "msgpack-1.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3055b0455e45810820db1f29d900bf39466df96ddca11dfa6d074fa47054376d"}, - {file = "msgpack-1.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a61215eac016f391129a013c9e46f3ab308db5f5ec9f25811e811f96962599a8"}, - {file = "msgpack-1.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:362d9655cd369b08fda06b6657a303eb7172d5279997abe094512e919cf74b11"}, - {file = "msgpack-1.0.5-cp39-cp39-win32.whl", hash = "sha256:ac9dd47af78cae935901a9a500104e2dea2e253207c924cc95de149606dc43cc"}, - {file = "msgpack-1.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:06f5174b5f8ed0ed919da0e62cbd4ffde676a374aba4020034da05fab67b9164"}, - {file = "msgpack-1.0.5.tar.gz", hash = "sha256:c075544284eadc5cddc70f4757331d99dcbc16b2bbd4849d15f8aae4cf36d31c"}, -] - -[[package]] -name = "packaging" -version = "24.0" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, -] - -[[package]] -name = "pep8-naming" -version = "0.4.1" -description = "Check PEP-8 naming conventions, plugin for flake8" -optional = false -python-versions = "*" -groups = ["dev"] -files = [ - {file = "pep8-naming-0.4.1.tar.gz", hash = "sha256:4eedfd4c4b05e48796f74f5d8628c068ff788b9c2b08471ad408007fc6450e5a"}, - {file = "pep8_naming-0.4.1-py2.py3-none-any.whl", hash = "sha256:1b419fa45b68b61cd8c5daf4e0c96d28915ad14d3d5f35fcc1e7e95324a33a2e"}, -] - -[[package]] -name = "pluggy" -version = "1.2.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, - {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "py" -version = "1.11.0" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["dev"] -files = [ - {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, - {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, -] - -[[package]] -name = "pycodestyle" -version = "2.7.0" -description = "Python style guide checker" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["dev"] -files = [ - {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, - {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, -] - -[[package]] -name = "pycrypto" -version = "2.6.1" -description = "Cryptographic modules for Python." -optional = true -python-versions = "*" -groups = ["main"] -markers = "extra == \"oldcrypto\"" -files = [ - {file = "pycrypto-2.6.1.tar.gz", hash = "sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c"}, -] - -[[package]] -name = "pycryptodome" -version = "3.23.0" -description = "Cryptographic library for Python" -optional = true -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["main"] -markers = "extra == \"crypto\"" -files = [ - {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, - {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, - {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"}, - {file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"}, - {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"}, - {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"}, - {file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"}, - {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"}, - {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"}, - {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"}, - {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"}, - {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"}, - {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"}, - {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"}, - {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"}, - {file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"}, - {file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"}, - {file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"}, - {file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"}, - {file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"}, - {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"}, - {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"}, - {file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"}, -] - -[[package]] -name = "pyee" -version = "9.1.1" -description = "A port of node.js's EventEmitter to python." -optional = false -python-versions = "*" -groups = ["main"] -markers = "python_version == \"3.7\"" -files = [ - {file = "pyee-9.1.1-py2.py3-none-any.whl", hash = "sha256:f4a9853503d2f5a69d4350b54ba70841ebc535c53ebfaaa40c0fb47e63e78b3e"}, - {file = "pyee-9.1.1.tar.gz", hash = "sha256:a1ebcd38d92e7d780635ab3442c0f6ce49840d9bfe0b0549921a6188860af0db"}, -] - -[package.dependencies] -typing-extensions = "*" - -[[package]] -name = "pyee" -version = "13.0.0" -description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version >= \"3.8\"" -files = [ - {file = "pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498"}, - {file = "pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37"}, -] - -[package.dependencies] -typing-extensions = "*" - -[package.extras] -dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"] - -[[package]] -name = "pyflakes" -version = "2.3.1" -description = "passive checker of Python programs" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -groups = ["dev"] -files = [ - {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, - {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, -] - -[[package]] -name = "pytest" -version = "7.4.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-cov" -version = "2.12.1" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["dev"] -files = [ - {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, - {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, -] - -[package.dependencies] -coverage = ">=5.2.1" -pytest = ">=4.6" -toml = "*" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] - -[[package]] -name = "pytest-forked" -version = "1.6.0" -description = "run tests in isolated forked subprocesses" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pytest-forked-1.6.0.tar.gz", hash = "sha256:4dafd46a9a600f65d822b8f605133ecf5b3e1941ebb3588e943b4e3eb71a5a3f"}, - {file = "pytest_forked-1.6.0-py3-none-any.whl", hash = "sha256:810958f66a91afb1a1e2ae83089d8dc1cd2437ac96b12963042fbb9fb4d16af0"}, -] - -[package.dependencies] -py = "*" -pytest = ">=3.10" - -[[package]] -name = "pytest-rerunfailures" -version = "13.0" -description = "pytest plugin to re-run tests to eliminate flaky failures" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.7\"" -files = [ - {file = "pytest-rerunfailures-13.0.tar.gz", hash = "sha256:e132dbe420bc476f544b96e7036edd0a69707574209b6677263c950d19b09199"}, - {file = "pytest_rerunfailures-13.0-py3-none-any.whl", hash = "sha256:34919cb3fcb1f8e5d4b940aa75ccdea9661bade925091873b7c6fa5548333069"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=1", markers = "python_version < \"3.8\""} -packaging = ">=17.1" -pytest = ">=7" - -[[package]] -name = "pytest-rerunfailures" -version = "14.0" -description = "pytest plugin to re-run tests to eliminate flaky failures" -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version >= \"3.8\"" -files = [ - {file = "pytest-rerunfailures-14.0.tar.gz", hash = "sha256:4a400bcbcd3c7a4ad151ab8afac123d90eca3abe27f98725dc4d9702887d2e92"}, - {file = "pytest_rerunfailures-14.0-py3-none-any.whl", hash = "sha256:4197bdd2eaeffdbf50b5ea6e7236f47ff0e44d1def8dae08e409f536d84e7b32"}, -] - -[package.dependencies] -packaging = ">=17.1" -pytest = ">=7.2" - -[[package]] -name = "pytest-timeout" -version = "2.4.0" -description = "pytest plugin to abort hanging tests" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"}, - {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[[package]] -name = "pytest-xdist" -version = "1.34.0" -description = "pytest xdist plugin for distributed testing and loop-on-failing modes" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -groups = ["dev"] -files = [ - {file = "pytest-xdist-1.34.0.tar.gz", hash = "sha256:340e8e83e2a4c0d861bdd8d05c5d7b7143f6eea0aba902997db15c2a86be04ee"}, - {file = "pytest_xdist-1.34.0-py2.py3-none-any.whl", hash = "sha256:ba5d10729372d65df3ac150872f9df5d2ed004a3b0d499cc0164aafedd8c7b66"}, -] - -[package.dependencies] -execnet = ">=1.1" -pytest = ">=4.4.0" -pytest-forked = "*" -six = "*" - -[package.extras] -testing = ["filelock"] - -[[package]] -name = "respx" -version = "0.20.2" -description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version == \"3.7\"" -files = [ - {file = "respx-0.20.2-py2.py3-none-any.whl", hash = "sha256:ab8e1cf6da28a5b2dd883ea617f8130f77f676736e6e9e4a25817ad116a172c9"}, - {file = "respx-0.20.2.tar.gz", hash = "sha256:07cf4108b1c88b82010f67d3c831dae33a375c7b436e54d87737c7f9f99be643"}, -] - -[package.dependencies] -httpx = ">=0.21.0" - -[[package]] -name = "respx" -version = "0.22.0" -description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -markers = "python_version >= \"3.8\"" -files = [ - {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"}, - {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"}, -] - -[package.dependencies] -httpx = ">=0.25.0" - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["dev"] -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, - {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, -] - -[[package]] -name = "tokenize-rt" -version = "5.0.0" -description = "A wrapper around the stdlib `tokenize` which roundtrips." -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "tokenize_rt-5.0.0-py2.py3-none-any.whl", hash = "sha256:c67772c662c6b3dc65edf66808577968fb10badfc2042e3027196bed4daf9e5a"}, - {file = "tokenize_rt-5.0.0.tar.gz", hash = "sha256:3160bc0c3e8491312d0485171dea861fc160a240f5f5766b72a1165408d10740"}, -] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -groups = ["dev"] -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -markers = "python_version < \"3.11\"" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, -] -markers = {dev = "python_version < \"3.11\""} - -[[package]] -name = "vcdiff-decoder" -version = "0.1.0" -description = "Python implementation of VCDIFF (RFC 3284) delta compression format" -optional = false -python-versions = "<4.0,>=3.7" -groups = ["main", "dev"] -files = [ - {file = "vcdiff_decoder-0.1.0-py3-none-any.whl", hash = "sha256:42f4e3d77b3bd4be881853858ee471a11d6a474fda375482d589b8576b91318f"}, - {file = "vcdiff_decoder-0.1.0.tar.gz", hash = "sha256:905d9c39fd451331301652c16b19505c16d323446fa4dffa745b2855aff5fe69"}, -] - -[[package]] -name = "websockets" -version = "11.0.3" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.7" -groups = ["main"] -markers = "python_version == \"3.7\"" -files = [ - {file = "websockets-11.0.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3ccc8a0c387629aec40f2fc9fdcb4b9d5431954f934da3eaf16cdc94f67dbfac"}, - {file = "websockets-11.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d67ac60a307f760c6e65dad586f556dde58e683fab03323221a4e530ead6f74d"}, - {file = "websockets-11.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d27a4832cc1a0ee07cdcf2b0629a8a72db73f4cf6de6f0904f6661227f256f"}, - {file = "websockets-11.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffd7dcaf744f25f82190856bc26ed81721508fc5cbf2a330751e135ff1283564"}, - {file = "websockets-11.0.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7622a89d696fc87af8e8d280d9b421db5133ef5b29d3f7a1ce9f1a7bf7fcfa11"}, - {file = "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"}, - {file = "websockets-11.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:54c6e5b3d3a8936a4ab6870d46bdd6ec500ad62bde9e44462c32d18f1e9a8e54"}, - {file = "websockets-11.0.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:41f696ba95cd92dc047e46b41b26dd24518384749ed0d99bea0a941ca87404c4"}, - {file = "websockets-11.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86d2a77fd490ae3ff6fae1c6ceaecad063d3cc2320b44377efdde79880e11526"}, - {file = "websockets-11.0.3-cp310-cp310-win32.whl", hash = "sha256:2d903ad4419f5b472de90cd2d40384573b25da71e33519a67797de17ef849b69"}, - {file = "websockets-11.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:1d2256283fa4b7f4c7d7d3e84dc2ece74d341bce57d5b9bf385df109c2a1a82f"}, - {file = "websockets-11.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e848f46a58b9fcf3d06061d17be388caf70ea5b8cc3466251963c8345e13f7eb"}, - {file = "websockets-11.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa5003845cdd21ac0dc6c9bf661c5beddd01116f6eb9eb3c8e272353d45b3288"}, - {file = "websockets-11.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b58cbf0697721120866820b89f93659abc31c1e876bf20d0b3d03cef14faf84d"}, - {file = "websockets-11.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:660e2d9068d2bedc0912af508f30bbeb505bbbf9774d98def45f68278cea20d3"}, - {file = "websockets-11.0.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1f0524f203e3bd35149f12157438f406eff2e4fb30f71221c8a5eceb3617b6b"}, - {file = "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"}, - {file = "websockets-11.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b30c6590146e53149f04e85a6e4fcae068df4289e31e4aee1fdf56a0dead8f97"}, - {file = "websockets-11.0.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:619d9f06372b3a42bc29d0cd0354c9bb9fb39c2cbc1a9c5025b4538738dbffaf"}, - {file = "websockets-11.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:01f5567d9cf6f502d655151645d4e8b72b453413d3819d2b6f1185abc23e82dd"}, - {file = "websockets-11.0.3-cp311-cp311-win32.whl", hash = "sha256:e1459677e5d12be8bbc7584c35b992eea142911a6236a3278b9b5ce3326f282c"}, - {file = "websockets-11.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:e7837cb169eca3b3ae94cc5787c4fed99eef74c0ab9506756eea335e0d6f3ed8"}, - {file = "websockets-11.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9f59a3c656fef341a99e3d63189852be7084c0e54b75734cde571182c087b152"}, - {file = "websockets-11.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2529338a6ff0eb0b50c7be33dc3d0e456381157a31eefc561771ee431134a97f"}, - {file = "websockets-11.0.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:34fd59a4ac42dff6d4681d8843217137f6bc85ed29722f2f7222bd619d15e95b"}, - {file = "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"}, - {file = "websockets-11.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6505c1b31274723ccaf5f515c1824a4ad2f0d191cec942666b3d0f3aa4cb4007"}, - {file = "websockets-11.0.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f467ba0050b7de85016b43f5a22b46383ef004c4f672148a8abf32bc999a87f0"}, - {file = "websockets-11.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:9d9acd80072abcc98bd2c86c3c9cd4ac2347b5a5a0cae7ed5c0ee5675f86d9af"}, - {file = "websockets-11.0.3-cp37-cp37m-win32.whl", hash = "sha256:e590228200fcfc7e9109509e4d9125eace2042fd52b595dd22bbc34bb282307f"}, - {file = "websockets-11.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:b16fff62b45eccb9c7abb18e60e7e446998093cdcb50fed33134b9b6878836de"}, - {file = "websockets-11.0.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fb06eea71a00a7af0ae6aefbb932fb8a7df3cb390cc217d51a9ad7343de1b8d0"}, - {file = "websockets-11.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8a34e13a62a59c871064dfd8ffb150867e54291e46d4a7cf11d02c94a5275bae"}, - {file = "websockets-11.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4841ed00f1026dfbced6fca7d963c4e7043aa832648671b5138008dc5a8f6d99"}, - {file = "websockets-11.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a073fc9ab1c8aff37c99f11f1641e16da517770e31a37265d2755282a5d28aa"}, - {file = "websockets-11.0.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68b977f21ce443d6d378dbd5ca38621755f2063d6fdb3335bda981d552cfff86"}, - {file = "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"}, - {file = "websockets-11.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bee9fcb41db2a23bed96c6b6ead6489702c12334ea20a297aa095ce6d31370d0"}, - {file = "websockets-11.0.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4b253869ea05a5a073ebfdcb5cb3b0266a57c3764cf6fe114e4cd90f4bfa5f5e"}, - {file = "websockets-11.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1553cb82942b2a74dd9b15a018dce645d4e68674de2ca31ff13ebc2d9f283788"}, - {file = "websockets-11.0.3-cp38-cp38-win32.whl", hash = "sha256:f61bdb1df43dc9c131791fbc2355535f9024b9a04398d3bd0684fc16ab07df74"}, - {file = "websockets-11.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:03aae4edc0b1c68498f41a6772d80ac7c1e33c06c6ffa2ac1c27a07653e79d6f"}, - {file = "websockets-11.0.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:777354ee16f02f643a4c7f2b3eff8027a33c9861edc691a2003531f5da4f6bc8"}, - {file = "websockets-11.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8c82f11964f010053e13daafdc7154ce7385ecc538989a354ccc7067fd7028fd"}, - {file = "websockets-11.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3580dd9c1ad0701169e4d6fc41e878ffe05e6bdcaf3c412f9d559389d0c9e016"}, - {file = "websockets-11.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f1a3f10f836fab6ca6efa97bb952300b20ae56b409414ca85bff2ad241d2a61"}, - {file = "websockets-11.0.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df41b9bc27c2c25b486bae7cf42fccdc52ff181c8c387bfd026624a491c2671b"}, - {file = "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"}, - {file = "websockets-11.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1fdf26fa8a6a592f8f9235285b8affa72748dc12e964a5518c6c5e8f916716f7"}, - {file = "websockets-11.0.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:69269f3a0b472e91125b503d3c0b3566bda26da0a3261c49f0027eb6075086d1"}, - {file = "websockets-11.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:97b52894d948d2f6ea480171a27122d77af14ced35f62e5c892ca2fae9344311"}, - {file = "websockets-11.0.3-cp39-cp39-win32.whl", hash = "sha256:c7f3cb904cce8e1be667c7e6fef4516b98d1a6a0635a58a57528d577ac18a128"}, - {file = "websockets-11.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c792ea4eabc0159535608fc5658a74d1a81020eb35195dd63214dcf07556f67e"}, - {file = "websockets-11.0.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:f2e58f2c36cc52d41f2659e4c0cbf7353e28c8c9e63e30d8c6d3494dc9fdedcf"}, - {file = "websockets-11.0.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de36fe9c02995c7e6ae6efe2e205816f5f00c22fd1fbf343d4d18c3d5ceac2f5"}, - {file = "websockets-11.0.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ac56b661e60edd453585f4bd68eb6a29ae25b5184fd5ba51e97652580458998"}, - {file = "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"}, - {file = "websockets-11.0.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:42cc5452a54a8e46a032521d7365da775823e21bfba2895fb7b77633cce031bb"}, - {file = "websockets-11.0.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:e6316827e3e79b7b8e7d8e3b08f4e331af91a48e794d5d8b099928b6f0b85f20"}, - {file = "websockets-11.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8531fdcad636d82c517b26a448dcfe62f720e1922b33c81ce695d0edb91eb931"}, - {file = "websockets-11.0.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c114e8da9b475739dde229fd3bc6b05a6537a88a578358bc8eb29b4030fac9c9"}, - {file = "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"}, - {file = "websockets-11.0.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:92b2065d642bf8c0a82d59e59053dd2fdde64d4ed44efe4870fa816c1232647b"}, - {file = "websockets-11.0.3-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0ee68fe502f9031f19d495dae2c268830df2760c0524cbac5d759921ba8c8e82"}, - {file = "websockets-11.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcacf2c7a6c3a84e720d1bb2b543c675bf6c40e460300b628bab1b1efc7c034c"}, - {file = "websockets-11.0.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b67c6f5e5a401fc56394f191f00f9b3811fe843ee93f4a70df3c389d1adf857d"}, - {file = "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"}, - {file = "websockets-11.0.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ed058398f55163a79bb9f06a90ef9ccc063b204bb346c4de78efc5d15abfe602"}, - {file = "websockets-11.0.3-py3-none-any.whl", hash = "sha256:6681ba9e7f8f3b19440921e99efbb40fc89f26cd71bf539e45d8c8a25c976dc6"}, - {file = "websockets-11.0.3.tar.gz", hash = "sha256:88fc51d9a26b10fc331be344f1781224a375b78488fc343620184e95a4b27016"}, -] - -[[package]] -name = "websockets" -version = "13.1" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.8" -groups = ["main"] -markers = "python_version == \"3.8\"" -files = [ - {file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"}, - {file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"}, - {file = "websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6"}, - {file = "websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b"}, - {file = "websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa"}, - {file = "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"}, - {file = "websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c"}, - {file = "websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0"}, - {file = "websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f"}, - {file = "websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe"}, - {file = "websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a"}, - {file = "websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19"}, - {file = "websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5"}, - {file = "websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd"}, - {file = "websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02"}, - {file = "websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7"}, - {file = "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"}, - {file = "websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084"}, - {file = "websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3"}, - {file = "websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9"}, - {file = "websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f"}, - {file = "websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557"}, - {file = "websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc"}, - {file = "websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49"}, - {file = "websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6"}, - {file = "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"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf"}, - {file = "websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c"}, - {file = "websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3"}, - {file = "websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6"}, - {file = "websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708"}, - {file = "websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418"}, - {file = "websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a"}, - {file = "websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f"}, - {file = "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"}, - {file = "websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135"}, - {file = "websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2"}, - {file = "websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6"}, - {file = "websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d"}, - {file = "websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2"}, - {file = "websockets-13.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d"}, - {file = "websockets-13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23"}, - {file = "websockets-13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c"}, - {file = "websockets-13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea"}, - {file = "websockets-13.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7"}, - {file = "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"}, - {file = "websockets-13.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db"}, - {file = "websockets-13.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295"}, - {file = "websockets-13.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96"}, - {file = "websockets-13.1-cp38-cp38-win32.whl", hash = "sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf"}, - {file = "websockets-13.1-cp38-cp38-win_amd64.whl", hash = "sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6"}, - {file = "websockets-13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d"}, - {file = "websockets-13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7"}, - {file = "websockets-13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a"}, - {file = "websockets-13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa"}, - {file = "websockets-13.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa"}, - {file = "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"}, - {file = "websockets-13.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17"}, - {file = "websockets-13.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6"}, - {file = "websockets-13.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5"}, - {file = "websockets-13.1-cp39-cp39-win32.whl", hash = "sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c"}, - {file = "websockets-13.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d"}, - {file = "websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238"}, - {file = "websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5"}, - {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9"}, - {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6"}, - {file = "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"}, - {file = "websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23"}, - {file = "websockets-13.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b"}, - {file = "websockets-13.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51"}, - {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7"}, - {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d"}, - {file = "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"}, - {file = "websockets-13.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978"}, - {file = "websockets-13.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e"}, - {file = "websockets-13.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09"}, - {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842"}, - {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb"}, - {file = "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"}, - {file = "websockets-13.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678"}, - {file = "websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f"}, - {file = "websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878"}, -] - -[[package]] -name = "websockets" -version = "15.0.1" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version >= \"3.9\"" -files = [ - {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, - {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, - {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, - {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, - {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, - {file = "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"}, - {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, - {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, - {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, - {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, - {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, - {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, - {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, - {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, - {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, - {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, - {file = "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"}, - {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, - {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, - {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, - {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, - {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, - {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, - {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, - {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, - {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, - {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, - {file = "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"}, - {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, - {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, - {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, - {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, - {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, - {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, - {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, - {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, - {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, - {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, - {file = "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"}, - {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, - {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, - {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, - {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, - {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, - {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, - {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, - {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, - {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, - {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, - {file = "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"}, - {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, - {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, - {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, - {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, - {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, - {file = "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"}, - {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, - {file = "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"}, - {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, - {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, - {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, -] - -[[package]] -name = "zipp" -version = "3.15.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.7" -groups = ["dev"] -files = [ - {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, - {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8 ; python_version < \"3.12\"", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] - -[extras] -crypto = ["pycryptodome"] -oldcrypto = ["pycrypto"] -vcdiff = ["vcdiff-decoder"] - -[metadata] -lock-version = "2.1" -python-versions = "^3.7" -content-hash = "3a940983ed78d6a830e1c9179eedaf2063dc58cc1fb882ab8a72a9d9be8ab903" diff --git a/pyproject.toml b/pyproject.toml index 4eedd26a..5876852b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,13 @@ -[tool.poetry] +[project] name = "ably" -version = "2.1.2" +version = "3.1.0" description = "Python REST and Realtime client library SDK for Ably realtime messaging service" -license = "Apache-2.0" -authors = ["Ably "] readme = "LONG_DESCRIPTION.rst" -homepage = "https://ably.com" -repository = "https://github.com/ably/ably-python" +requires-python = ">=3.7" +license = { text = "Apache-2.0" } +authors = [ + { name = "Ably", email = "support@ably.com" } +] classifiers = [ "Development Status :: 6 - Mature", "Intended Audience :: Developers", @@ -21,75 +22,97 @@ classifiers = [ "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", ] -include = [ - 'ably/**/*.py' +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'", ] -[[tool.poetry.source]] -name = "experimental" -url = "https://test.pypi.org/simple/" -priority = "explicit" - -[tool.poetry.dependencies] -python = "^3.7" - -# Mandatory dependencies -methoddispatch = "^5.0.1" -msgpack = "^1.0.0" -httpx = [ - { version = "^0.24.1", python = "~3.7" }, - { version = ">= 0.25.0, < 1.0", python = "^3.8" }, -] -h2 = "^4.1.0" # required for httx package, HTTP2 communication -websockets = [ - { version = ">= 10.0, < 12.0", python = "~3.7" }, - { version = ">= 12.0, < 15.0", python = "~3.8" }, - { version = ">= 15.0, < 16.0", python = ">=3.9" }, -] -pyee = [ - { version = "^9.0.4", python = "~3.7" }, - { version = ">=11.1.0, <14.0.0", python = "^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", ] -# Optional dependencies -pycrypto = { version = "^2.6.1", optional = true } -pycryptodome = { version = "*", optional = true } -vcdiff-decoder = { version = "^0.1.0", optional = true } +[project.scripts] +unasync = "ably.scripts.unasync:run" -[tool.poetry.extras] -oldcrypto = ["pycrypto"] -crypto = ["pycryptodome"] -vcdiff = ["vcdiff-decoder"] +[project.urls] +Homepage = "https://ably.com" +Repository = "https://github.com/ably/ably-python" -[tool.poetry.group.dev.dependencies] -pytest = "^7.1" -mock = "^4.0.3" -pep8-naming = "^0.4.1" -pytest-cov = "^2.4" -flake8="^3.9.2" -pytest-xdist = "^1.15" -respx = [ - { version = "^0.20.0", python = "~3.7" }, - { version = "^0.22.0", python = "^3.8" }, +[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" ] -importlib-metadata = "^4.12" -pytest-timeout = "^2.1.0" -pytest-rerunfailures = [ - { version = "^13.0", python = "~3.7" }, - { version = "^14.0", python = "^3.8" }, +exclude = [ + "**/*.pyc", + "**/__pycache__" ] -async-case = { version = "^10.1.0", python = "~3.7" } -tokenize_rt = "*" -vcdiff-decoder = { version = "^0.1.0a1" } -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +[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.poetry.scripts] -unasync = 'ably.scripts.unasync:run' +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # imported but unused diff --git a/setup.cfg b/setup.cfg index 727e7154..6171d1aa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,14 +1,5 @@ [coverage:run] branch=True -[flake8] -max-line-length = 115 -ignore = W503, W504, N818 -per-file-ignores = - # imported but unused - __init__.py: F401 -# Exclude virtual environment check -exclude = .venv,venv,env,.env,.git,__pycache__,.pytest_cache,build,dist,*.egg-info,ably/sync,test/ably/sync - [tool:pytest] #log_level = DEBUG diff --git a/test/ably/conftest.py b/test/ably/conftest.py index be61fec1..01483272 100644 --- a/test/ably/conftest.py +++ b/test/ably/conftest.py @@ -1,12 +1,10 @@ -import asyncio +import pytest_asyncio -import pytest from test.ably.testapp import TestApp -@pytest.fixture(scope='session', autouse=True) -def event_loop(): - loop = asyncio.get_event_loop_policy().new_event_loop() - loop.run_until_complete(TestApp.get_test_vars()) - yield loop - loop.run_until_complete(TestApp.clear_test_vars()) +@pytest_asyncio.fixture(scope='session', autouse=True) +async def test_app_setup(): + await TestApp.get_test_vars() + yield + await TestApp.clear_test_vars() diff --git a/test/ably/realtime/eventemitter_test.py b/test/ably/realtime/eventemitter_test.py index 873c2f65..71db2c74 100644 --- a/test/ably/realtime/eventemitter_test.py +++ b/test/ably/realtime/eventemitter_test.py @@ -1,11 +1,15 @@ 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): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() async def test_event_listener_error(self): 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 index 4011e621..6ec53356 100644 --- a/test/ably/realtime/realtimeauth_test.py +++ b/test/ably/realtime/realtimeauth_test.py @@ -1,8 +1,10 @@ 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 @@ -10,7 +12,6 @@ from ably.types.tokendetails import TokenDetails from test.ably.testapp import TestApp from test.ably.utils import BaseAsyncTestCase, random_string -import urllib.parse echo_url = 'https://echo.ably.io' 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 index a41c46b1..6d2865f2 100644 --- a/test/ably/realtime/realtimechannel_test.py +++ b/test/ably/realtime/realtimechannel_test.py @@ -1,16 +1,20 @@ import asyncio + import pytest -from ably.realtime.realtime_channel import ChannelState, RealtimeChannel, ChannelOptions + +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 -from ably.realtime.connection import ConnectionState -from ably.util.exceptions import AblyException class TestRealtimeChannel(BaseAsyncTestCase): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() self.valid_key_format = "api:key" diff --git a/test/ably/realtime/realtimechannel_vcdiff_test.py b/test/ably/realtime/realtimechannel_vcdiff_test.py index 75b8ce82..48a484a9 100644 --- a/test/ably/realtime/realtimechannel_vcdiff_test.py +++ b/test/ably/realtime/realtimechannel_vcdiff_test.py @@ -1,12 +1,14 @@ import asyncio import json +import pytest + from ably import AblyVCDiffDecoder -from ably.realtime.realtime_channel import ChannelOptions -from test.ably.testapp import TestApp -from test.ably.utils import BaseAsyncTestCase, WaitableEvent 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): @@ -31,7 +33,8 @@ def decode(self, delta: bytes, base: bytes) -> bytes: class TestRealtimeChannelVCDiff(BaseAsyncTestCase): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() self.valid_key_format = "api:key" diff --git a/test/ably/realtime/realtimechannelmutablemessages_test.py b/test/ably/realtime/realtimechannelmutablemessages_test.py new file mode 100644 index 00000000..047ea3b6 --- /dev/null +++ b/test/ably/realtime/realtimechannelmutablemessages_test.py @@ -0,0 +1,289 @@ +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) + 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 + ) + + await append_received.wait() + + assert messages_received[0].data == ' appended data' + assert messages_received[0].action == MessageAction.MESSAGE_APPEND + 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 + + 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 index 126c77f0..f1eb9003 100644 --- a/test/ably/realtime/realtimeconnection_test.py +++ b/test/ably/realtime/realtimeconnection_test.py @@ -1,15 +1,18 @@ import asyncio -from ably.realtime.connection import ConnectionEvent, ConnectionState + import pytest + +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 -from ably.transport.defaults import Defaults class TestRealtimeConnection(BaseAsyncTestCase): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() self.valid_key_format = "api:key" @@ -184,7 +187,7 @@ async def test_connectivity_check_bad_status(self): assert ably.connection.connection_manager.check_connection() is False async def test_unroutable_host(self): - ably = await TestApp.get_ably_realtime(realtime_host="10.255.255.1", realtime_request_timeout=3000) + 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 @@ -194,7 +197,7 @@ async def test_unroutable_host(self): await ably.close() async def test_invalid_host(self): - ably = await TestApp.get_ably_realtime(realtime_host="iamnotahost") + 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 @@ -296,8 +299,8 @@ async def test_fallback_host(self): await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) - assert ably.connection.connection_manager.transport.host != self.test_vars["realtime_host"] - assert ably.options.fallback_realtime_host != self.test_vars["realtime_host"] + 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): @@ -322,7 +325,7 @@ def check_connection(): await ably.connection.once_async(ConnectionState.DISCONNECTED) - assert ably.options.fallback_realtime_host is None + assert ably.options.fallback_host is None await ably.close() async def test_fallback_host_disconnected_protocol_msg(self): @@ -341,8 +344,8 @@ async def test_fallback_host_disconnected_protocol_msg(self): await asyncio.wait_for(ably.connection.once_async(ConnectionState.CONNECTED), timeout=5) - assert ably.connection.connection_manager.transport.host != self.test_vars["realtime_host"] - assert ably.options.fallback_realtime_host != self.test_vars["realtime_host"] + assert ably.connection.connection_manager.transport.host != self.test_vars["host"] + assert ably.options.fallback_host != self.test_vars["host"] await ably.close() # RTN2d @@ -366,7 +369,7 @@ async def test_connection_client_id_query_params(self): 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["client_id"] == client_id + assert ably.connection.connection_manager.transport.params["clientId"] == client_id assert ably.auth.client_id == client_id await ably.close() @@ -397,3 +400,72 @@ async def on_protocol_message(msg): 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 diff --git a/test/ably/realtime/realtimeinit_test.py b/test/ably/realtime/realtimeinit_test.py index ef8f99b4..4009d046 100644 --- a/test/ably/realtime/realtimeinit_test.py +++ b/test/ably/realtime/realtimeinit_test.py @@ -1,14 +1,17 @@ import asyncio -from ably.realtime.connection import ConnectionState + 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): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() self.valid_key_format = "api:key" 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 index 15ec73b2..bfe77efa 100644 --- a/test/ably/realtime/realtimeresume_test.py +++ b/test/ably/realtime/realtimeresume_test.py @@ -1,6 +1,9 @@ import asyncio + +import pytest + +from ably.realtime.channel import ChannelState from ably.realtime.connection import ConnectionState -from ably.realtime.realtime_channel import ChannelState from ably.transport.websockettransport import ProtocolMessageAction from test.ably.testapp import TestApp from test.ably.utils import BaseAsyncTestCase, random_string @@ -21,7 +24,8 @@ def on_message(_): class TestRealtimeResume(BaseAsyncTestCase): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() self.valid_key_format = "api:key" diff --git a/test/ably/rest/encoders_test.py b/test/ably/rest/encoders_test.py index 6bffba65..f8023c5d 100644 --- a/test/ably/rest/encoders_test.py +++ b/test/ably/rest/encoders_test.py @@ -2,14 +2,14 @@ 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 @@ -22,10 +22,10 @@ class TestTextEncodersNoEncryption(BaseAsyncTestCase): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.ably = await TestApp.get_ably_rest(use_binary_protocol=False) - - async def asyncTearDown(self): + yield await self.ably.close() async def test_text_utf8(self): @@ -144,12 +144,11 @@ def test_decode_with_invalid_encoding(self): class TestTextEncodersEncryption(BaseAsyncTestCase): - async def asyncSetUp(self): + @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') - - async def asyncTearDown(self): + self.cipher_params = CipherParams(secret_key='keyfordecrypt_16', algorithm='aes') + yield await self.ably.close() def decrypt(self, payload, options=None): @@ -258,10 +257,10 @@ async def test_with_json_list_data_decode(self): class TestBinaryEncodersNoEncryption(BaseAsyncTestCase): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.ably = await TestApp.get_ably_rest() - - async def asyncTearDown(self): + yield await self.ably.close() def decode(self, data): @@ -349,11 +348,11 @@ async def test_with_json_list_data_decode(self): class TestBinaryEncodersEncryption(BaseAsyncTestCase): - async def asyncSetUp(self): + @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') - - async def asyncTearDown(self): + yield await self.ably.close() def decrypt(self, payload, options=None): 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 index 656dbf86..185021e1 100644 --- a/test/ably/rest/restauth_test.py +++ b/test/ably/rest/restauth_test.py @@ -1,23 +1,20 @@ +import base64 import logging import sys import time import uuid -import base64 - +from unittest import mock from urllib.parse import parse_qs -import mock + import pytest import respx -from httpx import Response, AsyncClient +from httpx import AsyncClient, Response import ably -from ably import AblyRest -from ably import Auth -from ably import AblyAuthException +from ably import AblyAuthException, AblyRest, Auth from ably.types.tokendetails import TokenDetails - from test.ably.testapp import TestApp -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseAsyncTestCase +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol if sys.version_info >= (3, 8): from unittest.mock import AsyncMock @@ -29,7 +26,8 @@ # does not make any request, no need to vary by protocol class TestAuth(BaseAsyncTestCase): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() def test_auth_init_key_only(self): @@ -92,7 +90,7 @@ async def test_request_basic_auth_header(self): 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') + 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): @@ -119,7 +117,8 @@ async def test_request_token_auth_header(self): 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') + 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): @@ -169,11 +168,11 @@ def test_with_default_token_params(self): class TestAuthAuthorize(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.ably = await TestApp.get_ably_rest() self.test_vars = await TestApp.get_test_vars() - - async def asyncTearDown(self): + yield await self.ably.close() def per_protocol_setup(self, use_binary_protocol): @@ -221,7 +220,7 @@ async def test_authorize_adheres_to_request_token(self): # Authorize 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) + 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() @@ -324,7 +323,8 @@ async def test_client_id_precedence(self): class TestRequestToken(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() def per_protocol_setup(self, use_binary_protocol): @@ -482,16 +482,17 @@ async def test_client_id_null_until_auth(self): class TestRenewToken(BaseAsyncTestCase): - async def asyncSetUp(self): + @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, rest_host=self.host) + 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='https://{}'.format(self.host)) + 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") @@ -520,12 +521,11 @@ def call_back(request): }, ) - self.publish_attempt_route = self.mocked_api.post("/channels/{}/messages".format(self.channel), + 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() - - async def asyncTearDown(self): + 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() @@ -549,7 +549,7 @@ async def test_when_not_renewable(self): self.ably = await TestApp.get_ably_rest( key=None, - rest_host=self.host, + 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') @@ -568,7 +568,7 @@ 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, - rest_host=self.host, + endpoint=self.host, token_details=token_details, use_binary_protocol=False) await self.ably.channels[self.channel].publish('evt', 'msg') @@ -585,7 +585,8 @@ async def test_when_not_renewable_with_token_details(self): class TestRenewExpiredToken(BaseAsyncTestCase): - async def asyncSetUp(self): + @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 @@ -594,8 +595,8 @@ async def asyncSetUp(self): key = self.test_vars["keys"][0]['key_name'] headers = {'Content-Type': 'application/json'} - self.mocked_api = respx.mock(base_url='https://{}'.format(self.host)) - self.request_token_route = self.mocked_api.post("/keys/{}/requestToken".format(key), + 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, @@ -605,7 +606,7 @@ async def asyncSetUp(self): 'expires': int(time.time() * 1000), # Always expires } ) - self.publish_message_route = self.mocked_api.post("/channels/{}/messages".format(self.channel), + 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( @@ -631,14 +632,13 @@ def cb_publish(request): self.publish_message_route.side_effect = cb_publish self.mocked_api.start() - - async def asyncTearDown(self): + 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(rest_host=self.host) + 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') @@ -647,7 +647,7 @@ async def test_query_time_false(self): # RSA4b1 async def test_query_time_true(self): - ably = await TestApp.get_ably_rest(query_time=True, rest_host=self.host) + 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') diff --git a/test/ably/rest/restcapability_test.py b/test/ably/rest/restcapability_test.py index cb74ae8e..c95c651d 100644 --- a/test/ably/rest/restcapability_test.py +++ b/test/ably/rest/restcapability_test.py @@ -2,18 +2,17 @@ from ably.types.capability import Capability from ably.util.exceptions import AblyException - from test.ably.testapp import TestApp -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseAsyncTestCase +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol class TestRestCapability(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() self.ably = await TestApp.get_ably_rest() - - async def asyncTearDown(self): + yield await self.ably.close() def per_protocol_setup(self, use_binary_protocol): diff --git a/test/ably/rest/restchannelhistory_test.py b/test/ably/rest/restchannelhistory_test.py index d1ea1591..a9a2245b 100644 --- a/test/ably/rest/restchannelhistory_test.py +++ b/test/ably/rest/restchannelhistory_test.py @@ -1,23 +1,23 @@ 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 VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseAsyncTestCase +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol log = logging.getLogger(__name__) class TestRestChannelHistory(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @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() - - async def asyncTearDown(self): + yield await self.ably.close() def per_protocol_setup(self, use_binary_protocol): @@ -59,7 +59,7 @@ 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('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) history = await history0.history(direction='forwards') assert history is not None @@ -67,14 +67,14 @@ async def test_channel_history_multi_50_forwards(self): 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)] + 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('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) history = await history0.history(direction='backwards') assert history is not None @@ -82,7 +82,7 @@ async def test_channel_history_multi_50_backwards(self): 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)] + 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): @@ -133,7 +133,7 @@ async def test_channel_history_limit_forwards(self): history0 = self.get_channel('persisted:channelhistory_limit_f') for i in range(50): - await history0.publish('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) history = await history0.history(direction='forwards', limit=25) assert history is not None @@ -141,14 +141,14 @@ async def test_channel_history_limit_forwards(self): 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)] + 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('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) history = await history0.history(direction='backwards', limit=25) assert history is not None @@ -156,24 +156,24 @@ async def test_channel_history_limit_backwards(self): 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)] + 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('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) interval_start = await self.ably.time() for i in range(20, 40): - await history0.publish('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) interval_end = await self.ably.time() for i in range(40, 60): - await history0.publish('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) history = await history0.history(direction='forwards', start=interval_start, end=interval_end) @@ -182,24 +182,24 @@ async def test_channel_history_time_forwards(self): 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)] + 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('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) interval_start = await self.ably.time() for i in range(20, 40): - await history0.publish('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) interval_end = await self.ably.time() for i in range(40, 60): - await history0.publish('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) history = await history0.history(direction='backwards', start=interval_start, end=interval_end) @@ -208,14 +208,14 @@ async def test_channel_history_time_backwards(self): 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)] + 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('history%d' % i, str(i)) + await history0.publish(f'history{i}', str(i)) history = await history0.history(direction='forwards', limit=10) messages = history.items @@ -223,7 +223,7 @@ async def test_channel_history_paginate_forwards(self): 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)] + expected_messages = [message_contents[f'history{i}'] for i in range(0, 10)] assert expected_messages == messages, 'Expected 10 messages' history = await history.next() @@ -231,7 +231,7 @@ async def test_channel_history_paginate_forwards(self): 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)] + expected_messages = [message_contents[f'history{i}'] for i in range(10, 20)] assert expected_messages == messages, 'Expected 10 messages' history = await history.next() @@ -239,21 +239,21 @@ async def test_channel_history_paginate_forwards(self): 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)] + 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('history%d' % i, str(i)) + 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['history%d' % i] for i in range(49, 39, -1)] + 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() @@ -261,7 +261,7 @@ async def test_channel_history_paginate_backwards(self): 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)] + 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() @@ -269,20 +269,20 @@ async def test_channel_history_paginate_backwards(self): 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)] + 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('history%d' % i, str(i)) + 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['history%d' % i] for i in range(0, 10)] + expected_messages = [message_contents[f'history{i}'] for i in range(0, 10)] assert expected_messages == messages, 'Expected 10 messages' history = await history.next() @@ -290,7 +290,7 @@ async def test_channel_history_paginate_forwards_first(self): 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)] + expected_messages = [message_contents[f'history{i}'] for i in range(10, 20)] assert expected_messages == messages, 'Expected 10 messages' history = await history.first() @@ -298,21 +298,21 @@ async def test_channel_history_paginate_forwards_first(self): 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)] + 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('history%d' % i, str(i)) + 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['history%d' % i] for i in range(49, 39, -1)] + 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() @@ -320,7 +320,7 @@ async def test_channel_history_paginate_backwards_rel_first(self): 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)] + 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() @@ -328,5 +328,5 @@ async def test_channel_history_paginate_backwards_rel_first(self): 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)] + 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..7b144ab0 --- /dev/null +++ b/test/ably/rest/restchannelmutablemessages_test.py @@ -0,0 +1,296 @@ +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 + + async def wait_until_message_with_action_appears(self, channel, serial, action): + message: Message | None = None + async def check_message_action(): + nonlocal message + try: + message = await channel.get_message(serial) + return message.action == action + except Exception: + return False + + await assert_waiter(check_message_action) + + return message + + async def wait_until_get_all_message_version(self, channel, serial, count): + versions: List[Message] = [] + async def check_message_versions(): + nonlocal versions + versions = (await channel.get_message_versions(serial)).items + return len(versions) >= count + + await assert_waiter(check_message_versions) + + return versions diff --git a/test/ably/rest/restchannelpublish_test.py b/test/ably/rest/restchannelpublish_test.py index 4abb7381..41c2018b 100644 --- a/test/ably/rest/restchannelpublish_test.py +++ b/test/ably/rest/restchannelpublish_test.py @@ -4,22 +4,20 @@ import logging import os import uuid +from unittest import mock import httpx -import mock import msgpack import pytest -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 VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseAsyncTestCase, assert_waiter +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, assert_waiter, dont_vary_protocol log = logging.getLogger(__name__) @@ -28,13 +26,13 @@ @pytest.mark.filterwarnings('ignore::DeprecationWarning') class TestRestChannelPublish(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() self.ably = await TestApp.get_ably_rest() self.client_id = uuid.uuid4().hex self.ably_with_client_id = await TestApp.get_ably_rest(client_id=self.client_id, use_token_auth=True) - - async def asyncTearDown(self): + yield await self.ably.close() await self.ably_with_client_id.close() @@ -58,14 +56,14 @@ async def test_publish_various_datatypes_text(self): 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)" 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" @@ -84,7 +82,7 @@ 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)] await channel.publish(messages=expected_messages) @@ -103,7 +101,7 @@ 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: @@ -375,7 +373,7 @@ async def test_interoperability(self): name = self.get_channel_name('persisted:interoperability_channel') channel = self.ably.channels[name] - url = 'https://%s/channels/%s/messages' % (self.test_vars["host"], name) + url = 'https://{}/channels/{}/messages'.format(self.test_vars["host"], name) key = self.test_vars['keys'][0] auth = (key['key_name'], key['key_secret']) @@ -401,10 +399,9 @@ async def test_interoperability(self): expected_value = input_msg.get('expectedValue') # 1) - response = await channel.publish(data=expected_value) - assert response.status_code == 201 + await channel.publish(data=expected_value) - async def check_data(): + 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] @@ -417,13 +414,12 @@ async def check_data(): await assert_waiter(check_data) # 2) - response = await channel.publish(messages=[Message(data=msg_data, encoding=encoding)]) - assert response.status_code == 201 + await channel.publish(messages=[Message(data=msg_data, encoding=encoding)]) - async def check_history(): + 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 type(message.data) == type_mapping[expected_type] + return message.data == expected_value and isinstance(message.data, type_mapping[expected_type]) await assert_waiter(check_history) @@ -452,11 +448,11 @@ async def test_publish_params(self): class TestRestChannelPublishIdempotent(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @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) - - async def asyncTearDown(self): + yield await self.ably.close() await self.ably_idempotent.close() @@ -496,7 +492,7 @@ async 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 diff --git a/test/ably/rest/restchannels_test.py b/test/ably/rest/restchannels_test.py index fdeeb125..b5e59957 100644 --- a/test/ably/rest/restchannels_test.py +++ b/test/ably/rest/restchannels_test.py @@ -5,7 +5,6 @@ from ably import AblyException from ably.rest.channel import Channel, Channels, Presence from ably.util.crypto import generate_random_key - from test.ably.testapp import TestApp from test.ably.utils import BaseAsyncTestCase @@ -13,11 +12,11 @@ # makes no request, no need to use different protocols class TestChannels(BaseAsyncTestCase): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() self.ably = await TestApp.get_ably_rest() - - async def asyncTearDown(self): + yield await self.ably.close() def test_rest_channels_attr(self): @@ -62,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) diff --git a/test/ably/rest/restchannelstatus_test.py b/test/ably/rest/restchannelstatus_test.py index c1c6e5e1..cb455362 100644 --- a/test/ably/rest/restchannelstatus_test.py +++ b/test/ably/rest/restchannelstatus_test.py @@ -1,17 +1,19 @@ import logging +import pytest + from test.ably.testapp import TestApp -from test.ably.utils import VaryByProtocolTestsMetaclass, BaseAsyncTestCase +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass log = logging.getLogger(__name__) class TestRestChannelStatus(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.ably = await TestApp.get_ably_rest() - - async def asyncTearDown(self): + yield await self.ably.close() def per_protocol_setup(self, use_binary_protocol): diff --git a/test/ably/rest/restcrypto_test.py b/test/ably/rest/restcrypto_test.py index b6ea577b..94812b29 100644 --- a/test/ably/rest/restcrypto_test.py +++ b/test/ably/rest/restcrypto_test.py @@ -1,31 +1,29 @@ +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 Crypto import Random - +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 dont_vary_protocol, VaryByProtocolTestsMetaclass, BaseTestCase, BaseAsyncTestCase +from test.ably.utils import BaseAsyncTestCase, BaseTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol log = logging.getLogger(__name__) class TestRestCrypto(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @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() - - async def asyncTearDown(self): + yield await self.ably.close() await self.ably2.close() @@ -45,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" @@ -76,14 +74,14 @@ async def test_crypto_publish(self): 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" @@ -109,14 +107,14 @@ async def test_crypto_publish_256(self): 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" @@ -158,14 +156,14 @@ async def test_crypto_send_unencrypted(self): 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" @@ -203,20 +201,20 @@ def test_cipher_params(self): class AbstractTestCryptoWithFixture: - @classmethod - def setUpClass(cls): - resources_path = os.path.join(utils.get_submodule_dir(__file__), 'test-resources', cls.fixture_file) - with open(resources_path, '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 index b6df6be2..67d4f818 100644 --- a/test/ably/rest/resthttp_test.py +++ b/test/ably/rest/resthttp_test.py @@ -1,12 +1,11 @@ import base64 import re import time +from unittest import mock +from urllib.parse import urljoin import httpx -import mock import pytest -from urllib.parse import urljoin - import respx from httpx import Response @@ -53,9 +52,7 @@ async 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) + 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: @@ -67,13 +64,13 @@ def make_url(host): expected_urls_set = { make_url(host) - for host in Options(http_max_retry_count=10).get_rest_hosts() + 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_rest_hosts()) + 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')) @@ -82,7 +79,7 @@ def make_url(host): @respx.mock async def test_no_host_fallback_nor_retries_if_custom_host(self): custom_host = 'example.org' - ably = AblyRest(token="foo", rest_host=custom_host) + ably = AblyRest(token="foo", endpoint=custom_host) mock_route = respx.get("https://example.org").mock(side_effect=httpx.RequestError('')) @@ -98,7 +95,7 @@ async def test_no_host_fallback_nor_retries_if_custom_host(self): async def test_cached_fallback(self): timeout = 2000 ably = await TestApp.get_ably_rest(fallback_retry_timeout=timeout) - host = ably.options.get_rest_host() + host = ably.options.get_host() state = {'errors': 0} client = httpx.AsyncClient(http2=True) @@ -131,13 +128,10 @@ async def side_effect(*args, **kwargs): @respx.mock async def test_no_retry_if_not_500_to_599_http_code(self): - default_host = Options().get_rest_host() + default_host = Options().get_host() ably = AblyRest(token="foo") - default_url = "%s://%s:%d/" % ( - ably.http.preferred_scheme, - default_host, - ably.http.preferred_port) + 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}) @@ -186,7 +180,7 @@ async def test_request_headers(self): # API assert 'X-Ably-Version' in r.request.headers - assert r.request.headers['X-Ably-Version'] == '3' + assert r.request.headers['X-Ably-Version'] == '5' # Agent assert 'Ably-Agent' in r.request.headers @@ -221,7 +215,7 @@ async def test_request_over_http2(self): url = 'https://www.example.com' respx.get(url).mock(return_value=Response(status_code=200)) - ably = await TestApp.get_ably_rest(rest_host=url) + ably = await TestApp.get_ably_rest(endpoint=url) r = await ably.http.make_request('GET', url, skip_auth=True) assert r.http_version == 'HTTP/2' await ably.close() diff --git a/test/ably/rest/restinit_test.py b/test/ably/rest/restinit_test.py index 10dd8282..25e7c5af 100644 --- a/test/ably/rest/restinit_test.py +++ b/test/ably/rest/restinit_test.py @@ -1,19 +1,19 @@ -from mock import patch +from unittest.mock import patch + import pytest 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 VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseAsyncTestCase +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol class TestRestInit(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.test_vars = await TestApp.get_test_vars() @dont_vary_protocol @@ -69,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 @@ -100,16 +100,16 @@ 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 (RSC15g2) - ably = AblyRest(token='foo', environment='sandbox', http_max_retry_count=10) - assert sorted(Defaults.get_environment_fallback_hosts('sandbox')) == 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()) - # Fallback hosts and environment not specified (RSC15g3) + # Fallback hosts and endpoint not specified (RSC15g3) ably = AblyRest(token='foo', http_max_retry_count=10) - assert sorted(Defaults.fallback_hosts) == sorted(ably.options.get_fallback_rest_hosts()) + assert sorted(Defaults.get_fallback_hosts()) == sorted(ably.options.get_fallback_hosts()) # RSC15f ably = AblyRest(token='foo') @@ -118,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): @@ -182,13 +182,13 @@ async def test_query_time_param(self): @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 @@ -204,14 +204,14 @@ async def test_request_basic_auth_over_http_fails(self): @dont_vary_protocol async def test_environment(self): - ably = AblyRest(token='token', environment='custom') + ably = AblyRest(token='token', endpoint='custom') with patch.object(AsyncClient, 'send', wraps=ably.http._Http__client.send) as get_mock: try: await ably.time() except AblyException: pass request = get_mock.call_args_list[0][0][0] - assert request.url == 'https://custom-rest.ably.io:443/time' + assert request.url == 'https://custom.realtime.ably.net:443/time' await ably.close() diff --git a/test/ably/rest/restpaginatedresult_test.py b/test/ably/rest/restpaginatedresult_test.py index 1ad693bf..9aa85689 100644 --- a/test/ably/rest/restpaginatedresult_test.py +++ b/test/ably/rest/restpaginatedresult_test.py @@ -1,8 +1,8 @@ +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 @@ -16,7 +16,7 @@ def callback(request): return Response( status_code=status, headers=headers, - content='[{"page": %i}]' % int(res) + content=f'[{{"page": {int(res)}}}]' ) return Response( @@ -27,11 +27,12 @@ def callback(request): return callback - async def asyncSetUp(self): + @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://rest.ably.io') + 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'}, @@ -44,8 +45,8 @@ async def asyncSetUp(self): headers={ 'content-type': 'application/json', 'link': - '; rel="first",' - ' ; rel="next"' + '; rel="first",' + ' ; rel="next"' }, body='[{"id": 0}, {"id": 1}]', status=200 @@ -55,14 +56,13 @@ async def asyncSetUp(self): self.paginated_result = await PaginatedResult.paginated_query( self.ably.http, - url='http://rest.ably.io/channels/channel_name/ch1', + 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://rest.ably.io/channels/channel_name/ch2', + url='http://main.realtime.ably.net/channels/channel_name/ch2', response_processor=lambda response: response.to_native()) - - async def asyncTearDown(self): + yield self.mocked_api.stop() self.mocked_api.reset() await self.ably.close() diff --git a/test/ably/rest/restpresence_test.py b/test/ably/rest/restpresence_test.py index 2c525b02..8767b0c6 100644 --- a/test/ably/rest/restpresence_test.py +++ b/test/ably/rest/restpresence_test.py @@ -5,20 +5,19 @@ from ably.http.paginatedresult import PaginatedResult from ably.types.presence import PresenceMessage - -from test.ably.utils import dont_vary_protocol, VaryByProtocolTestsMetaclass, BaseAsyncTestCase from test.ably.testapp import TestApp +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol class TestPresence(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @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 - - async def asyncTearDown(self): + yield self.ably.channels.release('persisted:presence_fixtures') await self.ably.close() @@ -70,7 +69,7 @@ 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 == "%s:%s" % (member.connection_id, member.client_id) + assert member.member_key == f"{member.connection_id}:{member.client_id}" def presence_mock_url(self): kwargs = { @@ -189,12 +188,12 @@ async def test_with_start_gt_end(self): class TestPresenceCrypt(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @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}) - - async def asyncTearDown(self): + yield self.ably.channels.release('persisted:presence_fixtures') await self.ably.close() diff --git a/test/ably/rest/restpush_test.py b/test/ably/rest/restpush_test.py index f4a6a81a..867e8b90 100644 --- a/test/ably/rest/restpush_test.py +++ b/test/ably/rest/restpush_test.py @@ -5,26 +5,29 @@ import pytest -from ably import AblyException, AblyAuthException -from ably import DeviceDetails, PushChannelSubscription +from ably import AblyAuthException, AblyException, DeviceDetails, PushChannelSubscription from ably.http.paginatedresult import PaginatedResult - from test.ably.testapp import TestApp -from test.ably.utils import VaryByProtocolTestsMetaclass, BaseAsyncTestCase -from test.ably.utils import new_dict, random_string, get_random_key - +from test.ably.utils import ( + BaseAsyncTestCase, + VaryByProtocolTestsMetaclass, + get_random_key, + new_dict, + random_string, +) DEVICE_TOKEN = '740f4707bebcf74f9b7c25d48e3358945f6aa01da5ddb387462c7eaf61bb78ad' class TestPush(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.ably = await TestApp.get_ably_rest() # Register several devices for later use self.devices = {} - for i in range(10): + for _ in range(10): await self.save_device() # Register several subscriptions for later use @@ -33,8 +36,7 @@ async def asyncSetUp(self): device = self.devices[key] await self.save_subscription(channel, device_id=device.id) assert len(list(itertools.chain(*self.channels.values()))) == len(self.devices) - - async def asyncTearDown(self): + 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) @@ -251,7 +253,7 @@ async def test_admin_device_registrations_remove_where(self): 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 i in range(5): + for _ in range(5): time.sleep(1) await get(device.id) diff --git a/test/ably/rest/restrequest_test.py b/test/ably/rest/restrequest_test.py index 0f0cd623..967da19e 100644 --- a/test/ably/rest/restrequest_test.py +++ b/test/ably/rest/restrequest_test.py @@ -6,25 +6,24 @@ from ably.http.paginatedresult import HttpPaginatedResponse from ably.transport.defaults import Defaults from test.ably.testapp import TestApp -from test.ably.utils import BaseAsyncTestCase -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol # RSC19 class TestRestRequest(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @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 = '/channels/%s/messages' % self.channel + self.path = f'/channels/{self.channel}/messages' for i in range(20): - body = {'name': 'event%s' % i, 'data': 'lorem ipsum %s' % i} + body = {'name': f'event{i}', 'data': f'lorem ipsum {i}'} await self.ably.request('POST', self.path, body=body, version=Defaults.protocol_version) - - async def asyncTearDown(self): + yield await self.ably.close() def per_protocol_setup(self, use_binary_protocol): @@ -101,8 +100,8 @@ async def test_timeout(self): await ably.request('GET', '/time', version=Defaults.protocol_version) await ably.close() - default_endpoint = 'https://sandbox-rest.ably.io/time' - fallback_host = 'sandbox-a-fallback.ably-realtime.com' + 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: @@ -118,7 +117,7 @@ async def test_timeout(self): # Bad host, no Fallback ably = AblyRest(key=self.test_vars["keys"][0]["key_str"], - rest_host='some.other.host', + endpoint='some.other.host', port=self.test_vars["port"], tls_port=self.test_vars["tls_port"], tls=self.test_vars["tls"]) @@ -129,8 +128,8 @@ async def test_timeout(self): # RSC15l3 @dont_vary_protocol async def test_503_status_fallback(self): - default_endpoint = 'https://sandbox-rest.ably.io/time' - fallback_host = 'sandbox-a-fallback.ably-realtime.com' + 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: @@ -150,8 +149,8 @@ async def test_503_status_fallback(self): # RSC15l2 @dont_vary_protocol async def test_httpx_timeout_fallback(self): - default_endpoint = 'https://sandbox-rest.ably.io/time' - fallback_host = 'sandbox-a-fallback.ably-realtime.com' + 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: @@ -171,8 +170,8 @@ async def test_httpx_timeout_fallback(self): # RSC15l3 @dont_vary_protocol async def test_503_status_fallback_on_publish(self): - default_endpoint = 'https://sandbox-rest.ably.io/channels/test/messages' - fallback_host = 'sandbox-a-fallback.ably-realtime.com' + 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 = ( @@ -194,16 +193,15 @@ async def test_503_status_fallback_on_publish(self): headers=headers, text=fallback_response_text, ) - message_response = await ably.channels['test'].publish('test', 'data') + await ably.channels['test'].publish('test', 'data') assert default_route.called - assert message_response.to_native()['data'] == 'data' await ably.close() # RSC15l4 @dont_vary_protocol async def test_400_cloudfront_fallback(self): - default_endpoint = 'https://sandbox-rest.ably.io/time' - fallback_host = 'sandbox-a-fallback.ably-realtime.com' + 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: diff --git a/test/ably/rest/reststats_test.py b/test/ably/rest/reststats_test.py index ca0547b8..cef28817 100644 --- a/test/ably/rest/reststats_test.py +++ b/test/ably/rest/reststats_test.py @@ -1,15 +1,13 @@ -from datetime import datetime -from datetime import timedelta 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 ably.http.paginatedresult import PaginatedResult - from test.ably.testapp import TestApp -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseAsyncTestCase +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol log = logging.getLogger(__name__) @@ -25,7 +23,8 @@ def get_params(self): 'limit': 1 } - async def asyncSetUp(self): + @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) @@ -69,12 +68,10 @@ async def asyncSetUp(self): } ) # asynctest does not support setUpClass method - if TestRestAppStatsSetup.__stats_added: - return - await self.ably.http.post('/stats', body=stats + previous_stats) - TestRestAppStatsSetup.__stats_added = True - - async def asyncTearDown(self): + 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() diff --git a/test/ably/rest/resttime_test.py b/test/ably/rest/resttime_test.py index 6189ebd0..4b78620a 100644 --- a/test/ably/rest/resttime_test.py +++ b/test/ably/rest/resttime_test.py @@ -3,9 +3,8 @@ import pytest from ably import AblyException - from test.ably.testapp import TestApp -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseAsyncTestCase +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol class TestRestTime(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): @@ -14,10 +13,10 @@ 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 asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): self.ably = await TestApp.get_ably_rest() - - async def asyncTearDown(self): + yield await self.ably.close() async def test_time_accuracy(self): @@ -25,18 +24,18 @@ async def test_time_accuracy(self): actual_time = time.time() * 1000.0 seconds = 10 - assert abs(actual_time - reported_time) < seconds * 1000, "Time is not within %s seconds" % seconds + 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, "Time is not within %s seconds" % seconds + 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', rest_host="this.host.does.not.exist") + ably = await TestApp.get_ably_rest(key=None, token='foo', endpoint="this.host.does.not.exist") with pytest.raises(AblyException): await ably.time() diff --git a/test/ably/rest/resttoken_test.py b/test/ably/rest/resttoken_test.py index 9e74e695..5052f1be 100644 --- a/test/ably/rest/resttoken_test.py +++ b/test/ably/rest/resttoken_test.py @@ -1,18 +1,15 @@ 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.testapp import TestApp -from test.ably.utils import VaryByProtocolTestsMetaclass, dont_vary_protocol, BaseAsyncTestCase +from test.ably.utils import BaseAsyncTestCase, VaryByProtocolTestsMetaclass, dont_vary_protocol log = logging.getLogger(__name__) @@ -22,12 +19,12 @@ class TestRestToken(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): async def server_time(self): return await self.ably.time() - async def asyncSetUp(self): + @pytest.fixture(autouse=True) + async def setup(self): capability = {"*": ["*"]} self.permit_all = str(Capability(capability)) self.ably = await TestApp.get_ably_rest() - - async def asyncTearDown(self): + yield await self.ably.close() def per_protocol_setup(self, use_binary_protocol): @@ -160,12 +157,12 @@ async def test_request_token_float_and_timedelta(self): class TestCreateTokenRequest(BaseAsyncTestCase, metaclass=VaryByProtocolTestsMetaclass): - async def asyncSetUp(self): + @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 - - async def asyncTearDown(self): + yield await self.ably.close() def per_protocol_setup(self, use_binary_protocol): diff --git a/test/ably/testapp.py b/test/ably/testapp.py index 86741f3c..f657fdd4 100644 --- a/test/ably/testapp.py +++ b/test/ably/testapp.py @@ -1,36 +1,28 @@ import json -import os 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 -from ably.realtime.realtime import AblyRealtime log = logging.getLogger(__name__) -with open(os.path.dirname(__file__) + '/../assets/testAppSpec.json', 'r') as f: +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" -rest_host = os.environ.get('ABLY_REST_HOST', 'sandbox-rest.ably.io') -realtime_host = os.environ.get('ABLY_REALTIME_HOST', 'sandbox-realtime.ably.io') - -environment = os.environ.get('ABLY_ENV', 'sandbox') +endpoint = os.environ.get('ABLY_ENDPOINT', 'nonprod:sandbox') port = 80 tls_port = 443 -if rest_host and not rest_host.endswith("rest.ably.io"): - tls = tls and rest_host != "localhost" - port = 8080 - tls_port = 8081 - - ably = AblyRest(token='not_a_real_token', port=port, tls_port=tls_port, tls=tls, - environment=environment, + endpoint=endpoint, use_binary_protocol=False) @@ -49,16 +41,15 @@ async def get_test_vars(): test_vars = { "app_id": app_id, - "host": rest_host, "port": port, "tls_port": tls_port, "tls": tls, - "environment": environment, - "realtime_host": realtime_host, + "endpoint": endpoint, + "host": Defaults.get_hostname(endpoint), "keys": [{ - "key_name": "%s.%s" % (app_id, k.get("id", "")), + "key_name": "{}.{}".format(app_id, k.get("id", "")), "key_secret": k.get("value", ""), - "key_str": "%s.%s:%s" % (app_id, k.get("id", ""), 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", [])] } @@ -88,15 +79,12 @@ def get_options(test_vars, **kwargs): 'port': test_vars["port"], 'tls_port': test_vars["tls_port"], 'tls': test_vars["tls"], - 'environment': test_vars["environment"], + '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"] - if any(x in kwargs for x in ["rest_host", "realtime_host"]): - options["environment"] = None - options.update(kwargs) return options @@ -105,7 +93,6 @@ def get_options(test_vars, **kwargs): async def clear_test_vars(): test_vars = TestApp.__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"] diff --git a/test/ably/utils.py b/test/ably/utils.py index 8f383263..ae19e0b5 100644 --- a/test/ably/utils.py +++ b/test/ably/utils.py @@ -3,25 +3,18 @@ import os import random import string -import sys import time -import unittest -from typing import Callable, Awaitable - -if sys.version_info >= (3, 8): - from unittest import IsolatedAsyncioTestCase -else: - from async_case import IsolatedAsyncioTestCase +from typing import Awaitable, Callable +from unittest import mock import msgpack -import mock import respx from httpx import Response from ably.http.http import Http -class BaseTestCase(unittest.TestCase): +class BaseTestCase: def respx_add_empty_msg_pack(self, url, method='GET'): respx.route(method=method, url=url).return_value = Response( @@ -40,7 +33,7 @@ def get_channel(cls, prefix=''): return cls.ably.channels.get(name) -class BaseAsyncTestCase(IsolatedAsyncioTestCase): +class BaseAsyncTestCase: def respx_add_empty_msg_pack(self, url, method='GET'): respx.route(method=method, url=url).return_value = Response( @@ -195,7 +188,7 @@ async def assert_waiter(block: Callable[[], Awaitable[bool]], timeout: float = 1 try: await asyncio.wait_for(_poll_until_success(block), timeout=timeout) except asyncio.TimeoutError: - raise asyncio.TimeoutError(f"Condition not met within {timeout}s") + raise asyncio.TimeoutError(f"Condition not met within {timeout}s") from None async def _poll_until_success(block: Callable[[], Awaitable[bool]]) -> None: @@ -236,6 +229,9 @@ def assert_waiter_sync(block: Callable[[], bool], timeout: float = 10) -> None: class WaitableEvent: + """ + Replacement for asyncio.Future that will work with autogenerated sync tests. + """ def __init__(self): self._finished = False @@ -250,3 +246,22 @@ async def wait(self, timeout=10): 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 index 45f362ed..61e0d35e 100644 --- a/test/unit/http_test.py +++ b/test/unit/http_test.py @@ -3,17 +3,17 @@ def test_http_get_rest_hosts_works_when_fallback_realtime_host_is_set(): ably = AblyRest(token="foo") - ably.options.fallback_realtime_host = ably.options.get_rest_hosts()[0] + ably.options.fallback_host = ably.options.get_hosts()[0] # Should not raise TypeError - hosts = ably.http.get_rest_hosts() + 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_realtime_host = None + ably.options.fallback_host = None # Should not raise TypeError - hosts = ably.http.get_rest_hosts() + hosts = ably.http.get_hosts() assert isinstance(hosts, list) assert all(isinstance(host, str) for host in hosts) diff --git a/test/unit/mutable_message_test.py b/test/unit/mutable_message_test.py new file mode 100644 index 00000000..6f5afc92 --- /dev/null +++ b/test/unit/mutable_message_test.py @@ -0,0 +1,117 @@ +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 + +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/uv.lock b/uv.lock new file mode 100644 index 00000000..7ec12c09 --- /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.0" +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 = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[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" }, +]