diff --git a/.github/workflows/lint-test.yaml b/.github/workflows/lint-test.yaml index 257bac09..bb3658f0 100644 --- a/.github/workflows/lint-test.yaml +++ b/.github/workflows/lint-test.yaml @@ -118,6 +118,14 @@ jobs: strategy: matrix: python_version: [3.12, 3.13, 3.14] + # Each Python version has its own dedicated broker lane in plugboard-infra, so the + # matrix legs within a run execute concurrently. A per-version concurrency group with + # `queue: max` serialises same-version legs across concurrent pipeline runs (queueing + # up to 100 pending jobs in FIFO order rather than cancelling earlier pending ones). + concurrency: + group: test-integration-${{ matrix.python_version }} + cancel-in-progress: false + queue: max steps: - name: Checkout uses: actions/checkout@v4 @@ -142,12 +150,52 @@ jobs: sleep 10 # Wait for services to start - name: Run integration tests - run: COVERAGE_FILE=.coverage.py${{ matrix.python_version }}.integration uv run coverage run -m pytest ./tests/integration/ -m "not tuner" + # The message broker round trips are part of this suite and run on every Python + # leg. Each leg resolves its own dedicated broker lane (one SNS+SQS pair and + # Pub/Sub topic+subscription per Python version, provisioned in plugboard-infra + # and stored in the BROKER_LANES_JSON variable), so the legs run concurrently + # without contending on a shared queue or subscription. When broker tests are off + # the lane resolution is skipped and the broker tests self-skip. + run: | + # Resolve this leg's broker lane. The broker tests self-skip unless these env vars + # are set, so when broker tests are off, or lanes have not been provisioned for this + # run (e.g. a fork pull request where vars/secrets are unavailable), they simply skip. + if [ "${PLUGBOARD_MESSAGE_BROKER_TESTS}" = "true" ] && [ -n "${BROKER_LANES_JSON:-}" ]; then + # A lanes file is present but has no entry for this Python version: that is a + # configuration mismatch (version in the matrix but not in terraform), so fail loud. + if ! jq -e --arg v "$PYTHON_VERSION" 'has($v)' <<< "$BROKER_LANES_JSON" > /dev/null; then + echo "::error::No broker lane for Python $PYTHON_VERSION in BROKER_LANES_JSON" + exit 1 + fi + printf '%s' "$GCP_SA_KEY_JSON" > /tmp/gcp-sa-key.json + # The lane values are queue URLs, topic ARNs and resource ids (no shell metacharacters), + # so the generated `export` lines are safe to eval. + eval "$(jq -r --arg v "$PYTHON_VERSION" \ + '.[$v] | "export AWS_SQS_QUEUE_URL=\(.aws_sqs_queue_url)\nexport AWS_SNS_TOPIC_ARN=\(.aws_sns_topic_arn)\nexport GCP_TOPIC_ID=\(.gcp_topic_id)\nexport GCP_SUBSCRIPTION_ID=\(.gcp_subscription_id)"' \ + <<< "$BROKER_LANES_JSON")" + export GOOGLE_APPLICATION_CREDENTIALS=/tmp/gcp-sa-key.json + echo "Broker lane for Python $PYTHON_VERSION:" + echo " AWS_SQS_QUEUE_URL=$AWS_SQS_QUEUE_URL" + echo " GCP_TOPIC_ID=$GCP_TOPIC_ID" + fi + COVERAGE_FILE=.coverage.py${{ matrix.python_version }}.integration \ + uv run coverage run -m pytest ./tests/integration/ -m "not tuner" env: RABBITMQ_URL: amqp://user:password@localhost:5672/ REDIS_URL: redis://default:password@localhost:6379/ RAY_ENABLE_UV_RUN_RUNTIME_ENV: 0 PLUGBOARD_IO_READ_TIMEOUT: 5.0 + # Message broker test inputs. The per-lane identifiers are resolved from + # BROKER_LANES_JSON in the step above (only when the tests are enabled). + PYTHON_VERSION: ${{ matrix.python_version }} + PLUGBOARD_MESSAGE_BROKER_TESTS: ${{ vars.PLUGBOARD_MESSAGE_BROKER_TESTS }} + BROKER_LANES_JSON: ${{ vars.BROKER_LANES_JSON }} + GCP_SA_KEY_JSON: ${{ secrets.GCP_SA_KEY_JSON }} + AWS_REGION: ${{ vars.AWS_REGION }} + GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + GCP_PUBSUB_PROJECT_ID: ${{ vars.GCP_PUBSUB_PROJECT_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: Upload integration test coverage uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index f38593eb..8f9b933c 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Optional integrations for different cloud providers can be installed using `plug Support for parallelisation and hyperparameter optimisation can be installed using `plugboard[ray]`. -Additional optional extras: `plugboard[llm]` for LLM components, `plugboard[redis]` for Redis-based connectors, `plugboard[omq]` for the pyomq backend for ZMQ connectors, and `plugboard[websockets]` for WebSocket I/O. +Additional optional extras: `plugboard[llm]` for LLM components, `plugboard[redis]` for Redis-based connectors, `plugboard[omq]` for the pyomq backend for ZMQ connectors, `plugboard[websockets]` for WebSocket I/O, and `plugboard[gcp-pubsub]` or `plugboard[aws-messaging]` for [message data components](https://docs.plugboard.dev/latest/usage/message-data/). ## ⚡ Quickstart with AI diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 2f9e70db..1acd473d 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -24,6 +24,25 @@ Plugboard can make use of a message broker for data exchange between components | `RABBITMQ_URL` | URL for RabbitMQ AMQP message broker (must include credentials if required) | | | `REDIS_URL` | URL for Redis message broker (must include credentials if required) | | +These brokers carry data *between components* within a run. To read or write records +through an external broker as part of the model itself, see +[Message Data](message-data.md). + +### Message data brokers + +The message data components ([`MessageDataReader`][plugboard.library.MessageDataReader] / +[`MessageDataWriter`][plugboard.library.MessageDataWriter] and their broker +implementations) take connection details as constructor arguments, falling back to the +environment below when they are not supplied: + +| Option Name | Description | Default Value | +|---------------------------|----------------------------------------|---------------| +| `GCP_PUBSUB_PROJECT_ID` | GCP project for PubSub topics and subscriptions | | +| `AWS_REGION` | AWS region for SQS and SNS clients | | + +Note that `AWS_REGION` is also read by the AWS SDK itself, so setting it affects both +Plugboard's defaults and the credential/endpoint resolution of the underlying client. + ## Job ID Each plugboard run has a unique job ID associated with it. This is used to: track state for each run; and separate data messages between runs when using a message broker. Typically, a run would be started without explicitly setting the job ID, in which case a unique job ID will be created automatically. However, there are instances when it may be desirable to specify the job ID, such as stopping a run and resuming the same run later with the existing persisted state. In these scenarios the job ID can be set with the below environment variable which will then be used by any `StateBackend`, `Process` and `Component` while the value is set. diff --git a/docs/usage/message-data.md b/docs/usage/message-data.md new file mode 100644 index 00000000..f86f5ed6 --- /dev/null +++ b/docs/usage/message-data.md @@ -0,0 +1,172 @@ +# Message Data + +Message data components read and write **records** through a pub/sub message broker, +rather than through a channel wired between components. They are the broker equivalent +of [`FileReader`][plugboard.library.FileReader] / [`FileWriter`][plugboard.library.FileWriter]: +each message carries one record, and the fields named by `field_names` become the +component's outputs (for a reader) or inputs (for a writer). + +| Class | Broker | Reads from | Writes to | +|---|---|---|---| +| [`GCPPubSubDataReader`][plugboard.library.GCPPubSubDataReader] | Google Cloud PubSub | a subscription | | +| [`GCPPubSubDataWriter`][plugboard.library.GCPPubSubDataWriter] | Google Cloud PubSub | | a topic | +| [`AWSSQSDataReader`][plugboard.library.AWSSQSDataReader] | AWS SQS | a queue | | +| [`AWSSNSDataWriter`][plugboard.library.AWSSNSDataWriter] | AWS SNS | | a topic | + +All four build on the [`MessageDataReader`][plugboard.library.MessageDataReader] and +[`MessageDataWriter`][plugboard.library.MessageDataWriter] base classes, which handle +connecting, reconnection with backoff, batching and acknowledgment. + +## Installing + +Each broker uses its own client library, supplied as an extra: + +```shell +pip install "plugboard[gcp-pubsub]" # google-cloud-pubsub +pip install "plugboard[aws-messaging]" # aiobotocore +``` + +Constructing a component without its extra raises an `ImportError` naming the extra to +install. + +## Reading and writing records + +Messages are expected to carry a JSON object per record, whose keys match +`field_names`: + +```python +import asyncio + +from plugboard.component import Component, IOController as IO +from plugboard.connector import AsyncioConnector +from plugboard.library import AWSSNSDataWriter, GCPPubSubDataReader +from plugboard.process import LocalProcess +from plugboard.schemas import ConnectorSpec + + +class Bucket(Component): + """Turns a raw measurement into a bucketed label before it leaves the model.""" + + io = IO(inputs=["x", "y"], outputs=["bucket"]) + + async def step(self) -> None: + self.bucket = "high" if self.x + self.y > 50 else "low" + + +async def main() -> None: + reader = GCPPubSubDataReader( + name="reader", + subscription_id="measurements-pull", + # project_id is optional; it defaults to the GCP_PUBSUB_PROJECT_ID variable + field_names=["x", "y"], + chunk_size=20, + ) + bucket = Bucket(name="bucket") + writer = AWSSNSDataWriter( + name="writer", + topic_arn="arn:aws:sns:eu-west-1:123456789012:measurements", + region="eu-west-1", # Optional; defaults to AWS_REGION + field_names=["bucket"], + chunk_size=20, + ) + + process = LocalProcess( + name="measurements", + components=[reader, bucket, writer], + connectors=[ + AsyncioConnector(spec=ConnectorSpec(source="reader.x", target="bucket.x")), + AsyncioConnector(spec=ConnectorSpec(source="reader.y", target="bucket.y")), + AsyncioConnector(spec=ConnectorSpec(source="bucket.bucket", target="writer.bucket")), + ], + ) + async with process: + await process.run() + + +asyncio.run(main()) +``` + +The components are ordinary `Component`s, so they also work in a `RayProcess` and with +any connector. + +## Behaviour worth knowing + +- **A reader waits; it does not finish.** An empty poll means "nothing yet", not "no + more data" (unlike a file). A reader started before its producer keeps running and + picks up messages as they arrive; to end a run that would otherwise wait forever, stop + the process. A reader only stops on its own when the broker reports the source as gone + (for example a deleted subscription or queue), at which point it closes its stream and + the run ends. +- **Messages are acknowledged after they are processed.** A batch is acknowledged only + once every record in it has been published downstream, so a crash mid-batch leaves the + rest to be redelivered (at-least-once). For SQS, "acknowledge" means deleting the + messages. +- **Writers batch by `chunk_size`.** A writer flushes to the broker when its input + buffer reaches `chunk_size` records, and flushes whatever is left over when the run + ends. `chunk_size` defaults to `None`, so a writer with no `chunk_size` buffers every + record in memory and publishes them all at the end rather than streaming during the + run — set `chunk_size` (for example `chunk_size=1` for live per-record publishing) to + stream. For SQS the effective size is capped at 10 messages per send, the broker + limit. +- **Failures are retried with backoff.** Transient broker errors trigger a reconnect and + retry; permanent errors (such as `AccessDenied`) fail immediately. Override the policy + with the `retry_policy` argument, e.g. + `retry_policy=RetryPolicy(max_retries=5, base_delay=0.5, max_delay=30.0)`; an empty + poll pauses for `idle_poll_delay` seconds. +- **Encoding.** By default each record is JSON-encoded (`parse_json=True`). With + `parse_json=False` a writer sends only the first field's value and a reader exposes the + raw payload under a `data` field. +- **SNS fan-out is transparent.** Subscribing an SQS queue to an SNS topic delivers the + notification envelope, which `AWSSQSDataReader` unwraps automatically; a non-object + body is exposed under `data`. +- **Messages are scoped by `job_id`.** Writers attach the active Plugboard `job_id` as a + `job_id` message attribute, and readers filter incoming messages by `job_id` + (acknowledging and discarding non-matching messages). Pass `job_id=...` to override the + active process job ID when reading messages produced by another job. + +## Configuration + +Connection details are passed explicitly or read from the environment (explicit arguments +always win), so the same model can target a different account or region without code +changes. The message-data components read the `GCP_PUBSUB_PROJECT_ID` and `AWS_REGION` +environment variables; see [Configuration](configuration.md#message-data-brokers) for +how these are resolved. Credentials are never configured through Plugboard — see +[Credentials](#credentials) for the mechanisms each provider picks up. + +## Credentials + +Plugboard never configures, stores or refreshes credentials itself: the broker client +libraries resolve them from your environment, so authenticate **before** starting a run. +The mechanisms below are picked up in the provider's own precedence order — see the +provider documentation for full details. + +**AWS (SQS/SNS)** uses the +[standard credential chain](https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html): + +- Static keys in the environment: `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` + (plus `AWS_SESSION_TOKEN` for temporary credentials). +- Shared credentials/config files, optionally selected with `AWS_PROFILE` — including + IAM Identity Center (SSO) and assume-role profiles. +- The instance or container role when running on EC2/ECS/EKS. + +**GCP (PubSub)** uses +[Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials): + +- A service account key file pointed at by `GOOGLE_APPLICATION_CREDENTIALS`. +- User credentials from `gcloud auth application-default login`. +- The metadata server / Workload Identity when running on GCP infrastructure. + +### Long-running jobs and expiring sessions + +Credentials that renew themselves — instance/container roles, service account keys, and +ADC refresh tokens — keep working for the life of a long-running job: the client +libraries refresh them automatically as access tokens expire. + +IAM Identity Center (SSO) sessions are the exception: the login token is valid only for +your organisation's configured session length (1-12 hours, commonly 8) and can only be +renewed by an interactive `aws sso login`. If a job outlives the session, the next broker +call fails; the component retries with backoff — each reconnect re-runs credential +resolution, which heals any provider that can refresh — and then fails with the +provider's own error rather than a synthetic one. Prefer a non-interactive credential +source (static keys, an instance role, or a service account) for jobs that may run +longer than a login session. diff --git a/mkdocs.yaml b/mkdocs.yaml index 1dabbd9c..a78390a9 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -129,6 +129,7 @@ nav: - Event-driven models: examples/tutorials/event-driven-models.md - Tuning a process: examples/tutorials/tuning-a-process.md - Configuration: usage/configuration.md + - Message Data: usage/message-data.md - AI-Assisted Development: usage/ai.md - Topics: usage/topics.md - Demos: diff --git a/plugboard/exceptions/__init__.py b/plugboard/exceptions/__init__.py index 499a7a05..815778c8 100644 --- a/plugboard/exceptions/__init__.py +++ b/plugboard/exceptions/__init__.py @@ -116,3 +116,27 @@ class ProcessStatusError(Exception): """Raised when a `Process` is in an invalid state for the requested operation.""" pass + + +class MessageBrokerError(Exception): + """Base exception for message broker errors.""" + + pass + + +class MessageBrokerConnectionError(MessageBrokerError): + """Raised when connection to a message broker fails.""" + + pass + + +class MessageBrokerTransientError(MessageBrokerError): + """Raised on transient message broker errors (eligible for retry).""" + + pass + + +class MessageBrokerPermanentError(MessageBrokerError): + """Raised on permanent message broker errors (not eligible for retry).""" + + pass diff --git a/plugboard/library/__init__.py b/plugboard/library/__init__.py index b6909900..d674c903 100644 --- a/plugboard/library/__init__.py +++ b/plugboard/library/__init__.py @@ -1,20 +1,32 @@ """Provides implementations of Plugboard objects for use in user models.""" +from plugboard.utils.retry import RetryPolicy +from .aws_messaging_io import AWSSNSDataWriter, AWSSQSDataReader from .data_reader import DataReader from .data_writer import DataWriter from .file_io import FileReader, FileWriter +from .gcp_pubsub_io import GCPPubSubDataReader, GCPPubSubDataWriter from .llm import LLMChat, LLMImageProcessor +from .message_reader import MessageDataReader +from .message_writer import MessageDataWriter from .sql_io import SQLReader, SQLWriter from .websocket_io import WebsocketBase, WebsocketReader, WebsocketWriter __all__ = [ + "AWSSQSDataReader", + "AWSSNSDataWriter", "DataReader", "DataWriter", - "LLMChat", - "LLMImageProcessor", "FileReader", "FileWriter", + "GCPPubSubDataReader", + "GCPPubSubDataWriter", + "LLMChat", + "LLMImageProcessor", + "MessageDataReader", + "MessageDataWriter", + "RetryPolicy", "SQLReader", "SQLWriter", "WebsocketBase", diff --git a/plugboard/library/aws_messaging_io.py b/plugboard/library/aws_messaging_io.py new file mode 100644 index 00000000..37de35c2 --- /dev/null +++ b/plugboard/library/aws_messaging_io.py @@ -0,0 +1,414 @@ +"""Provides `AWSSQSDataReader` and `AWSSNSDataWriter` for AWS SQS/SNS messaging.""" + +from __future__ import annotations + +from collections import deque +import contextlib +import json +import typing as _t + +from plugboard.exceptions import ( + MessageBrokerConnectionError, + MessageBrokerPermanentError, + MessageBrokerTransientError, + NoMoreDataException, +) +from plugboard.library.message_reader import MessageDataReader, MessageDataReaderArgsDict +from plugboard.library.message_writer import ( + MessageDataWriter, + MessageDataWriterArgsDict, + encode_records, +) +from plugboard.utils import DI, depends_on_optional +from plugboard.utils.settings import resolve_argument + + +try: + from aiobotocore.session import get_session + from botocore.exceptions import BotoCoreError, ClientError + + _CLIENT_ERRORS: tuple[type[Exception], ...] = (ClientError,) + # BotoCoreError covers transport failures: timeouts, DNS and connection resets. + _TRANSPORT_ERRORS: tuple[type[Exception], ...] = (BotoCoreError,) +except ImportError: # pragma: no cover + # With the extra not installed, `depends_on_optional` blocks construction and + # classification is moot: `isinstance(error, ())` never matches. + _CLIENT_ERRORS = () + _TRANSPORT_ERRORS = () + + +# SQS returns at most this many messages per `ReceiveMessage` call. +_SQS_MAX_MESSAGES = 10 +# SQS accepts at most this many entries per `DeleteMessageBatch` call. +_SQS_MAX_BATCH = 10 + +_PERMANENT_CODES = frozenset( + { + "AccessDenied", + "AuthorizationError", + "IncompleteSignature", + "InvalidAddress", + "InvalidClientTokenId", + "InvalidMessageContents", + "MalformedXML", + "MessageNotInflight", + "ReceiptHandleIsInvalid", + "SignatureDoesNotMatch", + "UnrecognizedClientException", + } +) + +_TRANSIENT_CODES = frozenset( + { + "InternalError", + "RequestExpired", + "RequestThrottled", + "ServiceUnavailable", + "ThrottledException", + "ThrottlingException", + "TooManyRequestsException", + } +) + +_MISSING_RESOURCE_CODES = frozenset( + { + "AWS.SimpleQueueService.NonExistentQueue", + "NotFound", + "QueueDoesNotExist", + "NoSuchTopic", + } +) + + +def _parse_json_or_raw(text: str) -> _t.Any: + """Parses `text` as JSON, returning the raw text unchanged if it is not valid JSON.""" + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + return text + + +def _decode_body(body: str, *, parse_json: bool) -> dict[str, _t.Any]: + """Decodes an SQS message body into a record dict. + + Handles SNS fan-out: when an SNS topic is subscribed to the queue, SQS delivers the + SNS notification envelope rather than the raw payload, so the actual record is nested + under the envelope's ``Message`` field. A record that is not an object (a raw string + body, or a JSON scalar) is exposed under the ``data`` field. + + Args: + body: The raw SQS message body. + parse_json: Whether the record is JSON. + + Returns: + The decoded record as a dict. + """ + if not parse_json: + return {"data": body} + record = _parse_json_or_raw(body) + if isinstance(record, dict) and record.get("Type") == "Notification" and "Message" in record: + message = record["Message"] + if isinstance(message, str): + record = _parse_json_or_raw(message) + if not isinstance(record, dict): + return {"data": record} + return record + + +def _attr_string(attrs: _t.Any, key: str) -> _t.Optional[str]: + """Reads a string message attribute from an SQS or SNS `MessageAttributes` mapping.""" + if not isinstance(attrs, dict): + return None + entry = attrs.get(key) + if not isinstance(entry, dict): + return None + value = entry.get("StringValue") or entry.get("Value") + return value if isinstance(value, str) else None + + +def _extract_job_id(msg: dict[str, _t.Any]) -> _t.Optional[str]: + """Extracts the `job_id` attribute from an SQS message or its SNS envelope.""" + direct = _attr_string(msg.get("MessageAttributes"), "job_id") + if direct is not None: + return direct + body = msg.get("Body") + if not isinstance(body, str): + return None + parsed = _parse_json_or_raw(body) + if isinstance(parsed, dict) and parsed.get("Type") == "Notification": + return _attr_string(parsed.get("MessageAttributes"), "job_id") + return None + + +def _error_code(error: Exception) -> _t.Optional[str]: + """Extracts the AWS error code from a client error, if it has one.""" + if not isinstance(error, _CLIENT_ERRORS): + return None + response = getattr(error, "response", None) + if not isinstance(response, dict): + return None + return response.get("Error", {}).get("Code") + + +def _wrap_error(error: Exception, *, exhausted: bool = False) -> Exception: + """Maps an AWS SDK error onto the Plugboard message broker exception hierarchy. + + Args: + error: The error raised by the AWS client. + exhausted: Whether a missing queue/topic should be reported as an exhausted + source. Only meaningful for a reader: a writer cannot recover from a + missing topic, so it is a permanent failure there. + + Returns: + A `NoMoreDataException` for a deleted queue (readers only), a + `MessageBrokerPermanentError` for a rejection that will not change on retry, a + `MessageBrokerTransientError` for a failure worth retrying, otherwise the + original error. + """ + code = _error_code(error) + if code in _MISSING_RESOURCE_CODES: + return NoMoreDataException() if exhausted else MessageBrokerPermanentError(str(error)) + if code in _PERMANENT_CODES: + return MessageBrokerPermanentError(str(error)) + if code in _TRANSIENT_CODES or isinstance(error, _TRANSPORT_ERRORS): + return MessageBrokerTransientError(str(error)) + return error + + +class AWSSQSDataReader(MessageDataReader): + """Reads data from an AWS SQS queue. + + Messages are received using long polling and converted to field values. + Acknowledging a batch deletes its messages from the queue, which happens once all + of the batch's records have been consumed, so a failure before that point leaves + the remaining messages visible for redelivery. + """ + + @depends_on_optional("aiobotocore", extra="aws-messaging") + def __init__( + self, + queue_url: str, + region: _t.Optional[str] = None, + parse_json: bool = True, + wait_time_seconds: int = 20, + **kwargs: _t.Unpack[MessageDataReaderArgsDict], + ) -> None: + """Instantiates the `AWSSQSDataReader`. + + Args: + queue_url: The SQS queue URL. + region: Optional; The AWS region. Falls back to `AWS_REGION`. + parse_json: Whether to parse message bodies as JSON. + wait_time_seconds: Long-polling wait time in seconds (max 20). + **kwargs: Additional keyword arguments for + [`MessageDataReader`][plugboard.library.MessageDataReader]. + """ + region = resolve_argument( + region, DI.settings.resolve_sync().aws.region, arg_name="region", env_var="AWS_REGION" + ) + kwargs.setdefault("topic", queue_url) + super().__init__(**kwargs) + if self._chunk_size and self._chunk_size > _SQS_MAX_MESSAGES: + self._logger.warning( + "SQS returns at most 10 messages per receive, so chunk_size is capped", + chunk_size=self._chunk_size, + max_messages=_SQS_MAX_MESSAGES, + ) + self._queue_url = queue_url + self._region = region + self._parse_json = parse_json + self._wait_time_seconds = wait_time_seconds + self._client: _t.Any = None + self._exit_stack: _t.Optional[contextlib.AsyncExitStack] = None + + async def _connect(self) -> None: + """Creates an SQS client.""" + self._exit_stack = contextlib.AsyncExitStack() + self._client = await self._exit_stack.enter_async_context( + get_session().create_client("sqs", region_name=self._region) + ) + + async def _disconnect(self) -> None: + """Closes the SQS client.""" + if self._exit_stack is not None: + exit_stack = self._exit_stack + self._exit_stack = None + self._client = None + await exit_stack.aclose() + + async def _receive(self) -> list[_t.Any]: + """Receives a batch of messages from the SQS queue using long polling. + + Returns: + A list of SQS message dicts, empty if the long poll timed out. + + Raises: + NoMoreDataException: If the queue no longer exists. + MessageBrokerError: If the receive failed for a retryable or permanent reason. + """ + if self._client is None: + raise MessageBrokerConnectionError("SQS client is not connected") + request = { + "QueueUrl": self._queue_url, + "MaxNumberOfMessages": min(self._chunk_size or _SQS_MAX_MESSAGES, _SQS_MAX_MESSAGES), + "WaitTimeSeconds": self._wait_time_seconds, + "MessageAttributeNames": ["job_id"], + } + try: + response = await self._client.receive_message(**request) + except Exception as error: # noqa: BLE001 + raise _wrap_error(error, exhausted=True) from error + messages = response.get("Messages", []) + target_job_id = self.job_id + matching: list[_t.Any] = [] + unmatched: list[_t.Any] = [] + for msg in messages: + (matching if _extract_job_id(msg) == target_job_id else unmatched).append(msg) + if unmatched: + # Discarded under the connection lock, like every other acknowledgment, so a + # concurrent reconnect cannot swap the client mid-delete. + async with self._connection_lock: + await self._ack(unmatched) + return matching + + async def _convert(self, data: list[_t.Any]) -> dict[str, deque]: + """Converts SQS messages to a field buffer. + + Args: + data: A list of SQS message dicts. + + Returns: + A dictionary mapping field names to deques of field values. + """ + converted: dict[str, deque] = {field: deque() for field in self.io.outputs} + for msg in data: + record = _decode_body(msg.get("Body", ""), parse_json=self._parse_json) + for field in self.io.outputs: + converted[field].append(record.get(field)) + return converted + + async def _ack(self, messages: list[_t.Any]) -> None: + """Deletes processed messages from the SQS queue. + + Deletes run in batches of up to 10, the SQS batch limit. + + Args: + messages: The SQS message dicts to delete. + + Raises: + MessageBrokerConnectionError: If the client is not connected. + MessageBrokerTransientError: If any message could not be deleted. + """ + if self._client is None: + raise MessageBrokerConnectionError("SQS client is not connected") + for start in range(0, len(messages), _SQS_MAX_BATCH): + entries = [ + {"Id": str(index), "ReceiptHandle": msg["ReceiptHandle"]} + for index, msg in enumerate(messages[start : start + _SQS_MAX_BATCH]) + if msg.get("ReceiptHandle") + ] + if not entries: + continue + try: + response = await self._client.delete_message_batch( + QueueUrl=self._queue_url, Entries=entries + ) + except Exception as error: # noqa: BLE001 + raise _wrap_error(error) from error + if errors := response.get("Errors"): + # A partially failed delete leaves those messages visible for + # redelivery, so report it rather than pretending the batch is done. + raise MessageBrokerTransientError( + f"Failed to delete {len(errors)} SQS message(s): {errors}" + ) + + +class AWSSNSDataWriter(MessageDataWriter): + """Writes data to an AWS SNS topic. + + Field data is converted to JSON-encoded messages and published to the specified + topic. SNS has no batch publish API for standard topics, so records are published + one at a time per send. + """ + + @depends_on_optional("aiobotocore", extra="aws-messaging") + def __init__( + self, + topic_arn: str, + region: _t.Optional[str] = None, + parse_json: bool = True, + **kwargs: _t.Unpack[MessageDataWriterArgsDict], + ) -> None: + """Instantiates the `AWSSNSDataWriter`. + + Args: + topic_arn: The SNS topic ARN. + region: Optional; The AWS region. Falls back to `AWS_REGION`. + parse_json: Whether to encode message data as JSON. + **kwargs: Additional keyword arguments for + [`MessageDataWriter`][plugboard.library.MessageDataWriter]. + """ + region = resolve_argument( + region, DI.settings.resolve_sync().aws.region, arg_name="region", env_var="AWS_REGION" + ) + kwargs.setdefault("topic", topic_arn) + super().__init__(**kwargs) + self._topic_arn = topic_arn + self._region = region + self._parse_json = parse_json + self._client: _t.Any = None + self._exit_stack: _t.Optional[contextlib.AsyncExitStack] = None + + async def _connect(self) -> None: + """Creates an SNS client.""" + self._exit_stack = contextlib.AsyncExitStack() + self._client = await self._exit_stack.enter_async_context( + get_session().create_client("sns", region_name=self._region) + ) + + async def _disconnect(self) -> None: + """Closes the SNS client.""" + if self._exit_stack is not None: + exit_stack = self._exit_stack + self._exit_stack = None + self._client = None + await exit_stack.aclose() + + async def _send(self, messages: list[_t.Any]) -> None: + """Publishes messages to the SNS topic. + + Args: + messages: A list of message strings to publish. + + Raises: + MessageBrokerConnectionError: If the client is not connected. + MessageBrokerError: If a publish failed. + """ + if self._client is None: + raise MessageBrokerConnectionError("SNS client is not connected") + message_attributes = { + "job_id": { + "DataType": "String", + "StringValue": self.job_id, + } + } + for msg_data in messages: + try: + await self._client.publish( + TopicArn=self._topic_arn, + Message=msg_data, + MessageAttributes=message_attributes, + ) + except Exception as error: # noqa: BLE001 + raise _wrap_error(error) from error + + async def _convert(self, data: dict[str, deque]) -> list[_t.Any]: + """Converts field buffer data to JSON-encoded message strings. + + Args: + data: A dictionary mapping field names to deques of field values. + + Returns: + A list of message strings ready to publish. + """ + return encode_records(data, parse_json=self._parse_json) diff --git a/plugboard/library/gcp_pubsub_io.py b/plugboard/library/gcp_pubsub_io.py new file mode 100644 index 00000000..7c185c11 --- /dev/null +++ b/plugboard/library/gcp_pubsub_io.py @@ -0,0 +1,349 @@ +"""Provides `GCPPubSubDataReader` and `GCPPubSubDataWriter` for Google Cloud PubSub.""" + +from __future__ import annotations + +import asyncio +from collections import deque +import json +import typing as _t + +from plugboard.exceptions import ( + MessageBrokerConnectionError, + MessageBrokerPermanentError, + MessageBrokerTransientError, + NoMoreDataException, +) +from plugboard.library.message_reader import MessageDataReader, MessageDataReaderArgsDict +from plugboard.library.message_writer import ( + MessageDataWriter, + MessageDataWriterArgsDict, + encode_records_bytes, +) +from plugboard.utils import DI, depends_on_optional +from plugboard.utils.settings import resolve_argument + + +try: + from google.api_core import exceptions as gcp_exceptions + from google.auth import exceptions as auth_exceptions + from google.cloud import pubsub_v1 + + # PubSub rejections that retrying cannot resolve, so the retry loop should stop + # rather than back off repeatedly against a guaranteed failure. `RefreshError` is + # the google-auth failure to renew an expired access token (a revoked or dead + # refresh token): reconnecting cannot fix it, so it fails fast like the others. + _PERMANENT_ERRORS: tuple[type[Exception], ...] = ( + gcp_exceptions.PermissionDenied, + gcp_exceptions.Unauthorized, + gcp_exceptions.InvalidArgument, + gcp_exceptions.MethodNotImplemented, + gcp_exceptions.FailedPrecondition, + auth_exceptions.RefreshError, + ) + + # PubSub failures that are expected to clear on their own, so they are worth retrying. + _TRANSIENT_ERRORS: tuple[type[Exception], ...] = ( + gcp_exceptions.ServiceUnavailable, + gcp_exceptions.DeadlineExceeded, + gcp_exceptions.Aborted, + gcp_exceptions.InternalServerError, + gcp_exceptions.BadGateway, + gcp_exceptions.TooManyRequests, + ) + + _NOT_FOUND_ERRORS: tuple[type[Exception], ...] = (gcp_exceptions.NotFound,) +except ImportError: # pragma: no cover + # With the extra not installed, `depends_on_optional` blocks construction and + # classification is moot: `isinstance(error, ())` never matches. + _PERMANENT_ERRORS = () + _TRANSIENT_ERRORS = () + _NOT_FOUND_ERRORS = () + + +def _wrap_error(error: Exception, *, exhausted: bool = False) -> Exception: + """Maps a PubSub SDK error onto the Plugboard message broker exception hierarchy. + + Args: + error: The error raised by the PubSub client. + exhausted: Whether a missing topic/subscription should be reported as an + exhausted source. Only meaningful for a reader: a writer cannot recover from + a missing topic, so it is a permanent failure there. + + Returns: + A `NoMoreDataException` for a deleted topic/subscription (readers only), a + `MessageBrokerPermanentError` for a rejection that will not change on retry, a + `MessageBrokerTransientError` for a failure worth retrying, otherwise the + original error. + """ + if isinstance(error, _NOT_FOUND_ERRORS): + return NoMoreDataException() if exhausted else MessageBrokerPermanentError(str(error)) + if isinstance(error, _PERMANENT_ERRORS): + return MessageBrokerPermanentError(str(error)) + if isinstance(error, _TRANSIENT_ERRORS): + return MessageBrokerTransientError(str(error)) + return error + + +def _decode_payload(payload: bytes, *, parse_json: bool) -> dict[str, _t.Any]: + """Decodes a PubSub message payload into a record dict. + + A payload that is not valid UTF-8, is not JSON, or is a JSON scalar or array cannot + be read as a record, so it is exposed under the ``data`` field rather than raising. + + Args: + payload: The raw PubSub message data. + parse_json: Whether the record is JSON. + + Returns: + The decoded record as a dict. + """ + if not parse_json: + return {"data": payload} + try: + record = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return {"data": payload} + if not isinstance(record, dict): + return {"data": record} + return record + + +def _extract_job_id(msg_wrapper: _t.Any) -> _t.Optional[str]: + """Extracts the `job_id` attribute from a PubSub `ReceivedMessage`, if present.""" + message = getattr(msg_wrapper, "message", None) + attributes = getattr(message, "attributes", None) + if not isinstance(attributes, _t.Mapping): + return None + value = attributes.get("job_id") + return value if isinstance(value, str) else None + + +class GCPPubSubDataReader(MessageDataReader): + """Reads data from a Google Cloud PubSub subscription. + + Messages are pulled from the subscription in batches and converted to field + values. A batch is acknowledged once all of its records have been consumed, so a + failure before that point leaves the remaining messages for redelivery. + + The PubSub client library is synchronous, so every call is dispatched to a worker + thread to keep the process event loop free for other components. + """ + + @depends_on_optional("google.cloud.pubsub_v1", extra="gcp-pubsub") + def __init__( + self, + subscription_id: str, + project_id: _t.Optional[str] = None, + parse_json: bool = True, + pull_timeout: float = 30.0, + **kwargs: _t.Unpack[MessageDataReaderArgsDict], + ) -> None: + """Instantiates the `GCPPubSubDataReader`. + + Args: + subscription_id: The PubSub subscription ID. + project_id: Optional; The GCP project ID. Falls back to + `GCP_PUBSUB_PROJECT_ID`. + parse_json: Whether to parse message data as JSON. + pull_timeout: Seconds to wait for a `pull()` call to return. + **kwargs: Additional keyword arguments for + [`MessageDataReader`][plugboard.library.MessageDataReader]. + """ + project_id = resolve_argument( + project_id, + DI.settings.resolve_sync().gcp_pubsub.project_id, + arg_name="project_id", + env_var="GCP_PUBSUB_PROJECT_ID", + ) + kwargs.setdefault("topic", f"{project_id}/{subscription_id}") + super().__init__(**kwargs) + self._subscription_path = f"projects/{project_id}/subscriptions/{subscription_id}" + self._parse_json = parse_json + self._pull_timeout = pull_timeout + self._subscriber: _t.Optional[pubsub_v1.SubscriberClient] = None + + async def _connect(self) -> None: + """Creates a PubSub subscriber client.""" + self._subscriber = await asyncio.to_thread(pubsub_v1.SubscriberClient) + + async def _disconnect(self) -> None: + """Closes the PubSub subscriber client.""" + if self._subscriber is not None: + subscriber = self._subscriber + self._subscriber = None + await asyncio.to_thread(subscriber.close) + + async def _receive(self) -> list[_t.Any]: + """Pulls a batch of messages from the PubSub subscription. + + Returns: + A list of PubSub `ReceivedMessage` objects, empty if the pull timed out. + + Raises: + NoMoreDataException: If the subscription no longer exists. + MessageBrokerError: If the pull failed for a retryable or permanent reason. + """ + if self._subscriber is None: + raise MessageBrokerConnectionError("Subscriber client is not connected") + request = { + "subscription": self._subscription_path, + "max_messages": self._chunk_size or 10, + } + try: + response = await asyncio.to_thread( + self._subscriber.pull, request=request, timeout=self._pull_timeout + ) + except gcp_exceptions.DeadlineExceeded: + # The pull window elapsed on an empty subscription: this is "nothing yet", + # not a broker error, so it must not trigger a reconnect or fail the reader. + return [] + except Exception as error: # noqa: BLE001 + raise _wrap_error(error, exhausted=True) from error + messages = list(response.received_messages) + target_job_id = self.job_id + matching: list[_t.Any] = [] + unmatched: list[_t.Any] = [] + for msg in messages: + (matching if _extract_job_id(msg) == target_job_id else unmatched).append(msg) + if unmatched: + # Discarded under the connection lock, like every other acknowledgment, so a + # concurrent reconnect cannot swap the client mid-ack. + async with self._connection_lock: + await self._ack(unmatched) + return matching + + async def _convert(self, data: list[_t.Any]) -> dict[str, deque]: + """Converts PubSub messages to a field buffer. + + Args: + data: A list of `ReceivedMessage` objects. + + Returns: + A dictionary mapping field names to deques of field values. + """ + converted: dict[str, deque] = {field: deque() for field in self.io.outputs} + for msg_wrapper in data: + record = _decode_payload(msg_wrapper.message.data, parse_json=self._parse_json) + for field in self.io.outputs: + converted[field].append(record.get(field)) + return converted + + async def _ack(self, messages: list[_t.Any]) -> None: + """Acknowledges processed PubSub messages. + + Args: + messages: The `ReceivedMessage` objects to acknowledge. + + Raises: + MessageBrokerConnectionError: If the client is not connected. + MessageBrokerError: If the acknowledgment failed. + """ + if self._subscriber is None: + raise MessageBrokerConnectionError("Subscriber client is not connected") + ack_ids = [msg_wrapper.ack_id for msg_wrapper in messages] + request = {"subscription": self._subscription_path, "ack_ids": ack_ids} + try: + await asyncio.to_thread(self._subscriber.acknowledge, request=request) + except Exception as error: # noqa: BLE001 + raise _wrap_error(error) from error + + +class GCPPubSubDataWriter(MessageDataWriter): + """Writes data to a Google Cloud PubSub topic. + + Field data is converted to JSON-encoded messages and published to the specified + topic. The PubSub client library is synchronous, so publishing and waiting for + broker confirmation run in a worker thread. + """ + + @depends_on_optional("google.cloud.pubsub_v1", extra="gcp-pubsub") + def __init__( + self, + topic_id: str, + project_id: _t.Optional[str] = None, + parse_json: bool = True, + publish_timeout: float = 60.0, + **kwargs: _t.Unpack[MessageDataWriterArgsDict], + ) -> None: + """Instantiates the `GCPPubSubDataWriter`. + + Args: + topic_id: The PubSub topic ID. + project_id: Optional; The GCP project ID. Falls back to + `GCP_PUBSUB_PROJECT_ID`. + parse_json: Whether to encode message data as JSON. + publish_timeout: Seconds to wait for each publish to be confirmed. + **kwargs: Additional keyword arguments for + [`MessageDataWriter`][plugboard.library.MessageDataWriter]. + """ + project_id = resolve_argument( + project_id, + DI.settings.resolve_sync().gcp_pubsub.project_id, + arg_name="project_id", + env_var="GCP_PUBSUB_PROJECT_ID", + ) + kwargs.setdefault("topic", f"{project_id}/{topic_id}") + super().__init__(**kwargs) + self._topic_path = f"projects/{project_id}/topics/{topic_id}" + self._parse_json = parse_json + self._publish_timeout = publish_timeout + self._publisher: _t.Optional[pubsub_v1.PublisherClient] = None + + async def _connect(self) -> None: + """Creates a PubSub publisher client.""" + self._publisher = await asyncio.to_thread(pubsub_v1.PublisherClient) + + async def _disconnect(self) -> None: + """Stops the PubSub publisher, flushing any queued messages and closing its channel. + + Raises: + MessageBrokerError: If the publisher fails to stop. + """ + if self._publisher is not None: + publisher = self._publisher + self._publisher = None + try: + await asyncio.to_thread(publisher.stop) + except Exception as error: # noqa: BLE001 + raise _wrap_error(error) from error + + async def _send(self, messages: list[_t.Any]) -> None: + """Publishes messages to the PubSub topic and waits for broker confirmation. + + Args: + messages: A list of bytes objects to publish. + + Raises: + MessageBrokerConnectionError: If the client is not connected. + MessageBrokerError: If a publish failed or was not confirmed in time. + """ + if self._publisher is None: + raise MessageBrokerConnectionError("Publisher client is not connected") + publisher = self._publisher + job_id = self.job_id + + def _publish() -> None: + futures = [ + publisher.publish(self._topic_path, data=msg_data, job_id=job_id) + for msg_data in messages + ] + # The publisher batches internally; each future resolves once the broker + # has accepted that message. + for future in futures: + future.result(timeout=self._publish_timeout) + + try: + await asyncio.to_thread(_publish) + except Exception as error: # noqa: BLE001 + raise _wrap_error(error) from error + + async def _convert(self, data: dict[str, deque]) -> list[_t.Any]: + """Converts field buffer data to JSON-encoded bytes messages. + + Args: + data: A dictionary mapping field names to deques of field values. + + Returns: + A list of bytes objects ready to publish. + """ + return encode_records_bytes(data, parse_json=self._parse_json) diff --git a/plugboard/library/message_reader.py b/plugboard/library/message_reader.py new file mode 100644 index 00000000..d28d1ccd --- /dev/null +++ b/plugboard/library/message_reader.py @@ -0,0 +1,288 @@ +"""Provides `MessageDataReader` base class for reading data from pub/sub message brokers.""" + +from __future__ import annotations + +from abc import abstractmethod +import asyncio +from collections import deque +import contextlib +import typing as _t + +from plugboard.component import IOController +from plugboard.exceptions import NoMoreDataException +from plugboard.library.data_reader import DataReader, DataReaderArgsSpec +from plugboard.schemas import ComponentArgsDict +from plugboard.utils import DI +from plugboard.utils.retry import RetryPolicy, attempt_reconnect + + +class MessageDataReaderArgsDict(DataReaderArgsSpec): + """Specification of the `MessageDataReader` constructor arguments. + + Adds the message-broker-specific arguments to the `DataReader` ones. + + Attributes: + topic: The topic/queue/subscription to read from. + retry_policy: Optional; The backoff policy for retrying failed broker calls. + idle_poll_delay: Optional; Delay in seconds between polls that return no + messages. + job_id: Optional; The job ID to filter incoming messages by. Defaults to the + active Plugboard job ID. + """ + + topic: _t.NotRequired[str] + retry_policy: _t.NotRequired[RetryPolicy] + idle_poll_delay: _t.NotRequired[float] + job_id: _t.NotRequired[_t.Optional[str]] + + +class MessageDataReader(DataReader): + """Abstract base class for reading data from a pub/sub message broker. + + Extends [`DataReader`][plugboard.library.DataReader] with the parts a message + broker needs and a finite source does not: a long-lived connection, reconnection + with exponential backoff, and acknowledgment of the messages that were consumed. + + Unlike a file or table reader, an empty poll does not mean the stream is over - + it means the broker had nothing to hand out yet. `step()` therefore waits for the + next message instead of closing the stream, so a reader started before its + producer keeps running. A subclass signals a genuinely exhausted source by + raising [`NoMoreDataException`][plugboard.exceptions.NoMoreDataException] from + `_receive`, which closes the IO stream like `DataReader` does. + + Subclasses implement the broker-specific methods for connecting, receiving raw + messages, converting them to field buffers, and acknowledging processed messages. + """ + + io = IOController() + + def __init__( + self, + field_names: list[str], + topic: str = "", + chunk_size: _t.Optional[int] = None, + retry_policy: RetryPolicy = RetryPolicy(), + idle_poll_delay: float = 0.1, + job_id: _t.Optional[str] = None, + **kwargs: _t.Unpack[ComponentArgsDict], + ) -> None: + """Instantiates the `MessageDataReader`. + + Args: + field_names: The names of the fields to extract from messages. + topic: The topic/queue/subscription to read from. + chunk_size: Optional; The number of messages to fetch per batch. + retry_policy: The backoff policy for retrying failed broker calls. + idle_poll_delay: Delay in seconds before polling again after a poll that + returned no messages. + job_id: Optional; The job ID to filter incoming messages by. Defaults to the + active Plugboard job ID. + **kwargs: Additional keyword arguments for [`Component`][plugboard.component.Component]. + """ + super().__init__(field_names=field_names, chunk_size=chunk_size, **kwargs) + self._topic = topic + self._retry_policy = retry_policy + self._idle_poll_delay = idle_poll_delay + self._explicit_job_id = job_id + self._job_id: _t.Optional[str] = job_id + if self._job_id is None: + with contextlib.suppress(RuntimeError): + self._job_id = DI.job_id.resolve_sync() + self._pending_ack: list[_t.Any] = [] + self._connection_lock = asyncio.Lock() + + @property + def job_id(self) -> str: + """The job ID used to filter incoming messages.""" + if self._explicit_job_id is not None: + return self._explicit_job_id + if self._state is not None and self._state.job_id is not None: + return self._state.job_id + if self._job_id is not None: + return self._job_id + try: + self._job_id = DI.job_id.resolve_sync() + except RuntimeError: + with self._job_id_ctx(): + self._job_id = DI.job_id.resolve_sync() + return self._job_id + + @abstractmethod + async def _connect(self) -> None: + """Establishes connection to the message broker.""" + pass + + @abstractmethod + async def _disconnect(self) -> None: + """Closes the connection to the message broker.""" + pass + + @abstractmethod + async def _receive(self) -> list[_t.Any]: + """Receives a batch of raw messages from the broker. + + Should block until at least one message is available or a timeout occurs, and + return an empty list on timeout. Returning an empty list is *not* how a + subclass reports an exhausted source - raising `NoMoreDataException` is. + + Returns: + A list of raw broker-specific message objects. + + Raises: + NoMoreDataException: If the source is exhausted and no more messages will + arrive. + MessageBrokerPermanentError: If the failure cannot be recovered by retrying. + """ + pass + + @abstractmethod + async def _ack(self, messages: list[_t.Any]) -> None: + """Acknowledges successful processing of messages. + + Args: + messages: The raw messages to acknowledge. + """ + pass + + @abstractmethod + async def _convert(self, data: list[_t.Any]) -> dict[str, deque]: + """Converts raw messages into a `dict[str, deque]` field buffer. + + Args: + data: Raw broker-specific message objects, as returned by `_receive`. + + Returns: + A dictionary mapping field names to deques of field values. + """ + pass + + async def _fetch(self) -> list[_t.Any]: + """Receives a batch of messages, retrying transient failures. + + Returns: + A list of raw broker-specific message objects, possibly empty. + + Raises: + NoMoreDataException: If the source is exhausted. + MessageBrokerPermanentError: If the failure cannot be recovered by retrying. + Exception: The error from the final attempt, if retries are exhausted. + """ + return await self._retry_policy.run( + self._receive, + self._reconnect, + logger=self._logger, + description="receiving messages", + ) + + async def _reconnect(self) -> None: + """Attempts to reconnect to the message broker. + + Held under the connection lock so an in-flight acknowledgment cannot use a + client that is being replaced. + """ + async with self._connection_lock: + await attempt_reconnect( + self._connect, + self._disconnect, + logger=self._logger, + topic=self._topic, + ) + + async def _fetch_chunk(self) -> None: + """Fetches a batch of messages and updates the internal buffer. + + An empty batch leaves the buffer untouched, so the caller can poll again: a + broker with nothing to deliver has not ended the stream. The next batch is + requested concurrently so that waiting on the broker overlaps with processing. + """ + if self._task is None: + self._task = asyncio.create_task(self._fetch()) + task = self._task + # Clear the reference before awaiting so a failed fetch leaves no dangling task. + self._task = None + messages = await task + if not messages: + await asyncio.sleep(self._idle_poll_delay) + return + new_buffer = await self._convert(messages) + self._buffer = {field_name: new_buffer[field_name] for field_name in self.io.outputs} + self._pending_ack = messages + # Prefetch the next batch while the current one is being consumed. + self._task = asyncio.create_task(self._fetch()) + + @property + def _records_available(self) -> int: + """Calculates how many consumed-but-unpublished records the buffer holds.""" + if not self._buffer: + return 0 + return min(len(values) for values in self._buffer.values()) + + async def _ack_pending(self) -> None: + """Acknowledges the batch whose records have all been consumed, retrying transient failures. + + The acknowledgment and any reconnect run under the connection lock so neither can + use a client that a concurrent reconnect is replacing; the lock is held inside the + retried operation so a mid-retry reconnect does not deadlock on it. + """ + if not self._pending_ack: + return + pending = self._pending_ack + + async def ack() -> None: + async with self._connection_lock: + await self._ack(pending) + + await self._retry_policy.run( + ack, + self._reconnect, + logger=self._logger, + description="acknowledging messages", + ) + self._pending_ack = [] + + async def init(self) -> None: + """Initialises the `MessageDataReader`. + + Connects to the message broker and starts pre-fetching the first batch so that + the first `step()` does not wait on a cold poll. + """ + await self._connect() + self._logger.info("Connected to message broker", topic=self._topic) + self._task = asyncio.create_task(self._fetch()) + + async def step(self) -> None: + """Reads the next message from the broker and updates outputs. + + Waits until a message is available, then consumes one record. Once the batch's + last record has been consumed the whole batch is acknowledged, so a failure + before that point leaves the remaining messages for redelivery. + + An exhausted source closes the IO stream and returns (rather than raising): the + closed stream ends the run through `io.write`, exactly as it does for + `DataReader`, so the component completes instead of being marked failed. + """ + while not self._records_available: + try: + await self._fetch_chunk() + except NoMoreDataException: + await self._ack_pending() + await self.io.close() + return + self._consume_record() + if not self._records_available: + await self._ack_pending() + + async def destroy(self) -> None: + """Destroys the `MessageDataReader` and disconnects from the broker. + + Messages that were pre-fetched but never consumed are left unacknowledged so + the broker redelivers them to whoever reads the queue next. + """ + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._task + self._task = None + await self._disconnect() + self._logger.info("Disconnected from message broker", topic=self._topic) + await super().destroy() diff --git a/plugboard/library/message_writer.py b/plugboard/library/message_writer.py new file mode 100644 index 00000000..3b6cbc88 --- /dev/null +++ b/plugboard/library/message_writer.py @@ -0,0 +1,243 @@ +"""Provides `MessageDataWriter` base class for writing data to pub/sub message brokers.""" + +from __future__ import annotations + +from abc import abstractmethod +import asyncio +from collections import deque +import contextlib +import json +import typing as _t + +from plugboard.component import IOController +from plugboard.library.data_writer import DataWriter, DataWriterArgsSpec +from plugboard.schemas import ComponentArgsDict +from plugboard.utils import DI +from plugboard.utils.retry import RetryPolicy, attempt_reconnect + + +class MessageDataWriterArgsDict(DataWriterArgsSpec): + """Specification of the `MessageDataWriter` constructor arguments. + + Adds the message-broker-specific arguments to the `DataWriter` ones. + + Attributes: + topic: The topic/queue to write to. + retry_policy: Optional; The backoff policy for retrying failed broker calls. + job_id: Optional; The job ID to attach to outgoing messages. Defaults to the + active Plugboard job ID. + """ + + topic: _t.NotRequired[str] + retry_policy: _t.NotRequired[RetryPolicy] + job_id: _t.NotRequired[_t.Optional[str]] + + +def iter_records(data: dict[str, deque]) -> _t.Iterator[dict[str, _t.Any]]: + """Iterates over the complete records held in a field buffer. + + Args: + data: A dictionary mapping field names to deques of field values. + + Yields: + One dictionary per complete record, keyed by field name. + """ + fields = tuple(data) + if not fields: + return + for row in zip(*data.values()): + yield dict(zip(fields, row)) + + +def _raw_value(record: dict[str, _t.Any]) -> str: + """Returns the first field of a record as text, for non-JSON payloads.""" + value = next(iter(record.values())) + return value.decode("utf-8") if isinstance(value, bytes) else str(value) + + +def _raw_bytes(record: dict[str, _t.Any]) -> bytes: + """Returns the first field of a record as bytes, for non-JSON payloads. + + Byte values are passed through unchanged so binary payloads are not corrupted by + a text round trip. + """ + value = next(iter(record.values())) + return bytes(value) if isinstance(value, (bytes, bytearray)) else str(value).encode("utf-8") + + +def encode_records(data: dict[str, deque], *, parse_json: bool) -> list[str]: + """Encodes a field buffer as one text message per record. + + Args: + data: A dictionary mapping field names to deques of field values. + parse_json: Whether to encode each record as a JSON object. When false, the + first field's value is used as the message body. + + Returns: + A list of message strings ready to send. + """ + return [ + json.dumps(record) if parse_json else _raw_value(record) for record in iter_records(data) + ] + + +def encode_records_bytes(data: dict[str, deque], *, parse_json: bool) -> list[bytes]: + """Encodes a field buffer as one binary message per record. + + Args: + data: A dictionary mapping field names to deques of field values. + parse_json: Whether to encode each record as a JSON object. When false, the + first field's value is used as the message body. + + Returns: + A list of bytes objects ready to send. + """ + return [ + json.dumps(record).encode("utf-8") if parse_json else _raw_bytes(record) + for record in iter_records(data) + ] + + +class MessageDataWriter(DataWriter): + """Abstract base class for writing data to a pub/sub message broker. + + Extends [`DataWriter`][plugboard.library.DataWriter] with the parts a message + broker needs and a file or table does not: a long-lived connection, and + reconnection with exponential backoff around each send. Buffered records are + batched into messages by the broker-specific `_convert`. + + Subclasses implement the broker-specific methods for connecting, sending encoded + messages, and converting field data to the broker's message format. + """ + + io = IOController() + + def __init__( + self, + field_names: list[str], + topic: str = "", + chunk_size: _t.Optional[int] = None, + retry_policy: RetryPolicy = RetryPolicy(), + job_id: _t.Optional[str] = None, + **kwargs: _t.Unpack[ComponentArgsDict], + ) -> None: + """Instantiates the `MessageDataWriter`. + + Args: + field_names: The names of the fields to include in messages. + topic: The topic/queue to write to. + chunk_size: Optional; The number of records to batch into a single send. + retry_policy: The backoff policy for retrying failed broker calls. + job_id: Optional; The job ID to attach to outgoing messages. Defaults to the + active Plugboard job ID. + **kwargs: Additional keyword arguments for [`Component`][plugboard.component.Component]. + """ + super().__init__(field_names=field_names, chunk_size=chunk_size, **kwargs) + self._topic = topic + self._retry_policy = retry_policy + self._explicit_job_id = job_id + self._job_id: _t.Optional[str] = job_id + if self._job_id is None: + with contextlib.suppress(RuntimeError): + self._job_id = DI.job_id.resolve_sync() + + @property + def job_id(self) -> str: + """The job ID attached to outgoing messages.""" + if self._explicit_job_id is not None: + return self._explicit_job_id + if self._state is not None and self._state.job_id is not None: + return self._state.job_id + if self._job_id is not None: + return self._job_id + try: + self._job_id = DI.job_id.resolve_sync() + except RuntimeError: + with self._job_id_ctx(): + self._job_id = DI.job_id.resolve_sync() + return self._job_id + + @abstractmethod + async def _connect(self) -> None: + """Establishes connection to the message broker.""" + pass + + @abstractmethod + async def _disconnect(self) -> None: + """Closes the connection to the message broker.""" + pass + + @abstractmethod + async def _send(self, messages: list[_t.Any]) -> None: + """Sends a batch of messages to the broker. + + Args: + messages: A list of broker-specific message objects to send. + + Raises: + MessageBrokerConnectionError: If the connection to the broker fails. + MessageBrokerPermanentError: If the messages cannot be delivered and + retrying cannot help. + """ + pass + + @abstractmethod + async def _convert(self, data: dict[str, deque]) -> list[_t.Any]: + """Converts field buffer data into broker-specific message format. + + Args: + data: A dictionary mapping field names to deques of field values. + + Returns: + A list of broker-specific message objects ready to send. + """ + pass + + async def _save(self, data: list[_t.Any]) -> None: + """Sends encoded messages to the broker, retrying transient failures. + + Args: + data: The converted messages to send, as produced by `_convert`. + + Raises: + MessageBrokerPermanentError: If the failure cannot be recovered by retrying. + Exception: The error from the final attempt, if retries are exhausted. + """ + await self._retry_policy.run( + lambda: self._send(data), + self._reconnect, + logger=self._logger, + description="sending messages", + ) + + async def _reconnect(self) -> None: + """Re-establishes the broker connection, ignoring a failure to close the old one.""" + await attempt_reconnect( + self._connect, + self._disconnect, + logger=self._logger, + topic=self._topic, + ) + + async def init(self) -> None: + """Initialises the `MessageDataWriter` by connecting to the message broker.""" + await self._connect() + self._logger.info("Connected to message broker", topic=self._topic) + + async def destroy(self) -> None: + """Destroys the `MessageDataWriter` and disconnects from the broker. + + Any send still in flight is awaited rather than cancelled, so buffered data is + not lost on teardown. + """ + if self._task is not None: + try: + await self._task + except asyncio.CancelledError: # pragma: no cover + raise + except Exception as error: # noqa: BLE001 + self._logger.warning("Pending send failed during destroy", error=str(error)) + self._task = None + await self._disconnect() + self._logger.info("Disconnected from message broker", topic=self._topic) + await super().destroy() diff --git a/plugboard/utils/__init__.py b/plugboard/utils/__init__.py index 3c0d792e..7cb168f4 100644 --- a/plugboard/utils/__init__.py +++ b/plugboard/utils/__init__.py @@ -9,6 +9,7 @@ from plugboard.utils.random import gen_rand_str from plugboard.utils.ray import build_actor_wrapper, is_on_ray_worker from plugboard.utils.registry import ClassRegistry +from plugboard.utils.retry import RetryPolicy from plugboard.utils.settings import Settings @@ -25,5 +26,6 @@ "EntityIdGen", "Exportable", "ExportMixin", + "RetryPolicy", "Settings", ] diff --git a/plugboard/utils/retry.py b/plugboard/utils/retry.py new file mode 100644 index 00000000..2d6929a2 --- /dev/null +++ b/plugboard/utils/retry.py @@ -0,0 +1,138 @@ +"""Provides retry and backoff helpers for long-lived connections.""" + +import asyncio +from collections.abc import Awaitable, Callable +import typing as _t + +import msgspec +import structlog + +from plugboard.exceptions import MessageBrokerPermanentError, NoMoreDataException + + +_T = _t.TypeVar("_T") + + +class RetryPolicy(msgspec.Struct, frozen=True): + """Exponential backoff parameters for a retryable operation. + + The three values always travel together and drive a single backoff formula, so + they are grouped into one immutable value object. + + Attributes: + max_retries: Number of times to retry after a failure, so the operation runs + at most `max_retries + 1` times. + base_delay: Base delay in seconds, doubled on each consecutive attempt. + max_delay: Upper bound for the delay between attempts. + """ + + max_retries: int = 3 + base_delay: float = 1.0 + max_delay: float = 60.0 + + def delay_for(self, attempt: int) -> float: + """Calculates the delay to wait after the given zero-based attempt failed. + + Args: + attempt: The zero-based index of the attempt that failed. + + Returns: + The backoff delay in seconds, capped by `max_delay`. + """ + return min(self.base_delay * (2**attempt), self.max_delay) + + async def run( + self, + operation: Callable[[], Awaitable[_T]], + reconnect: Callable[[], Awaitable[None]], + *, + logger: structlog.BoundLogger, + description: str, + ) -> _T: + """Runs an operation, retrying transient failures with backoff and reconnection. + + Only transient failures are retried: `NoMoreDataException` means the source is + exhausted and `MessageBrokerPermanentError` means retrying cannot help, so both + propagate immediately. Every retry attempt re-establishes the connection before it + runs, so the operation never relies on a connection that a failed attempt may have + torn down. If the connection cannot be re-established, that attempt is skipped and + the retries continue, so the connection is retried on every attempt. When all + attempts are used up the last error is raised, so the caller sees the broker's own + error (or the reconnect failure) rather than a synthetic "not connected" one. + + Args: + operation: The coroutine function to run. + reconnect: The coroutine function that re-establishes the connection. + logger: Logger to report transient failures to. + description: Human-readable name of the operation, used in log messages. + + Returns: + Whatever `operation` returns. + + Raises: + NoMoreDataException: If the source is exhausted. + MessageBrokerPermanentError: If the failure is not retryable. + Exception: The last error encountered, if every attempt failed. + """ + last_error: Exception | None = None + for attempt in range(self.max_retries + 1): + if attempt: + delay = self.delay_for(attempt - 1) + logger.warning( + f"Transient error {description}, retrying", + attempt=attempt, + delay=delay, + ) + await asyncio.sleep(delay) + try: + await reconnect() + except Exception as reconnect_error: # noqa: BLE001 + # Without a connection the operation would fail with a synthetic + # "not connected" error, so it is skipped: the retries continue and the + # connection is tried again next attempt. `last_error` keeps the prior + # broker error, so what is eventually raised is always a real one. + logger.warning( + f"Reconnection for {description} failed, skipping attempt", + attempt=attempt, + error=str(reconnect_error), + ) + continue + try: + return await operation() + except (NoMoreDataException, MessageBrokerPermanentError): + raise + except Exception as error: # noqa: BLE001 + last_error = error + # Unreachable: the range is never empty, so at least one attempt ran and either + # returned, raised a non-retryable error, or recorded the last error. + if last_error is None: # pragma: no cover + raise RuntimeError("retry loop ended without recording an error") + raise last_error + + +async def attempt_reconnect( + connect: Callable[[], Awaitable[None]], + disconnect: Callable[[], Awaitable[None]], + *, + logger: structlog.BoundLogger, + topic: str, +) -> None: + """Re-establishes a message broker connection by disconnecting then connecting. + + A failure to disconnect is logged and ignored: the connection is assumed to be + broken already, and the new connection attempt is what matters. A failure to + connect propagates so the caller can count it as another failed attempt. + + Args: + connect: The coroutine function that establishes the connection. + disconnect: The coroutine function that closes the connection. + logger: Logger to report reconnection progress to. + topic: The topic/queue being connected to, used for log context. + """ + logger.info("Attempting reconnection to message broker", topic=topic) + try: + await disconnect() + except Exception as error: # noqa: BLE001 + logger.warning("Error during disconnect in reconnection", error=str(error)) + await connect() + logger.info("Reconnected to message broker", topic=topic) diff --git a/plugboard/utils/settings.py b/plugboard/utils/settings.py index 613f02a2..dfbea070 100644 --- a/plugboard/utils/settings.py +++ b/plugboard/utils/settings.py @@ -57,6 +57,30 @@ class _RedisSettings(BaseSettings): url: _t.Optional[str] = None +class _GCPPubSubSettings(BaseSettings): + """Google Cloud PubSub settings. + + Attributes: + project_id: The GCP project ID for PubSub. + """ + + model_config = SettingsConfigDict(env_prefix="GCP_PUBSUB_") + + project_id: _t.Optional[str] = None + + +class _AWSSettings(BaseSettings): + """AWS settings for SNS/SQS messaging. + + Attributes: + region: The default AWS region. + """ + + model_config = SettingsConfigDict(env_prefix="AWS_") + + region: _t.Optional[str] = None + + class Settings(BaseSettings): """Settings for Plugboard. @@ -69,6 +93,8 @@ class Settings(BaseSettings): status checks. rabbitmq: RabbitMQ settings. redis: Redis settings. + gcp_pubsub: Google Cloud PubSub settings. + aws: AWS settings for SNS/SQS messaging. """ model_config = SettingsConfigDict(env_prefix=_ENV_PREFIX) @@ -80,3 +106,42 @@ class Settings(BaseSettings): rabbitmq: _RabbitMQSettings = Field(default_factory=_RabbitMQSettings) redis: _RedisSettings = Field(default_factory=_RedisSettings) + gcp_pubsub: _GCPPubSubSettings = Field(default_factory=_GCPPubSubSettings) + aws: _AWSSettings = Field(default_factory=_AWSSettings) + + +_ArgumentT = _t.TypeVar("_ArgumentT") + + +def resolve_argument( + value: _t.Optional[_ArgumentT], + fallback: _t.Optional[_ArgumentT], + *, + arg_name: str, + env_var: str, +) -> _ArgumentT: + """Resolves a component argument against its configured fallback. + + Components accept connection details explicitly, but falling back to settings lets + the same model run in a different environment without code changes. + + Args: + value: The value passed to the component, if any. + fallback: The value loaded from `Settings`, if any. + arg_name: The name of the argument, used in the error message. + env_var: The environment variable that backs the fallback, used in the error + message. + + Returns: + The explicit value, or the configured value when none was passed. + + Raises: + ValueError: If neither an explicit value nor a configured value is available. + """ + resolved = value if value else fallback + if not resolved: + raise ValueError( + f"'{arg_name}' is required: pass it explicitly or set the '{env_var}' " + f"environment variable." + ) + return resolved diff --git a/pyproject.toml b/pyproject.toml index 8e418704..0e63df14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,10 @@ dependencies = [ [project.optional-dependencies] aws = ["s3fs>=2024.9.0"] +aws-messaging = ["aiobotocore>=3.4,<4"] azure = ["adlfs>=2024.7.0"] gcp = ["gcsfs>=2024.9.0"] +gcp-pubsub = ["google-cloud-pubsub>=2.25,<3"] llm = [ "llama-index-core>=0.12.30,<1", "llama-index-llms-openai>=0.3.33,<1", @@ -75,7 +77,11 @@ dev = [ test = [ "aiofile>=3.9,<4", "aiosqlite>=0.20,<1", + # Mirrors the optional broker extras so tests can import the real client classes + # and inject fakes, instead of leaving them unresolved to the type checker. + "aiobotocore>=3.4,<4", "coverage>=7.7.1,<8", + "google-cloud-pubsub>=2.25,<3", "llama-index-core>=0.12.30,<1", "llama-index-llms-openai>=0.3.33,<1", "moto[server]>=5.0,<6", diff --git a/tests/conftest.py b/tests/conftest.py index 3f4c63fa..66197b71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import asyncio from contextlib import contextmanager import multiprocessing +from types import SimpleNamespace import typing as _t from unittest.mock import patch @@ -17,10 +18,28 @@ from plugboard.component.io_controller import IOStreamClosedError from plugboard.connector import Connector from plugboard.schemas import Status +from plugboard.utils import retry as retry_utils from plugboard.utils.di import DI from plugboard.utils.settings import Settings +@pytest.fixture +def backoff_delays(monkeypatch: pytest.MonkeyPatch) -> _t.Iterator[list[float]]: + """Captures the non-zero backoff delays applied by the retry helper. + + Only the retry module's `asyncio` reference is replaced: patching `asyncio.sleep` + globally would also break the event loop teardown that waits for pending tasks. + """ + delays: list[float] = [] + + async def _fake_sleep(delay: float, *args: _t.Any) -> None: + if delay: # Idle poll pacing is not a backoff delay. + delays.append(delay) + + monkeypatch.setattr(retry_utils, "asyncio", _t.cast(_t.Any, SimpleNamespace(sleep=_fake_sleep))) + yield delays + + @contextmanager def override_settings(settings: Settings) -> _t.Iterator[None]: """Temporarily override DI settings for a test and always reset the override.""" diff --git a/tests/integration/test_message_data_brokers.py b/tests/integration/test_message_data_brokers.py new file mode 100644 index 00000000..46f88dd1 --- /dev/null +++ b/tests/integration/test_message_data_brokers.py @@ -0,0 +1,427 @@ +"""Integration tests for the message broker `MessageDataReader`/`MessageDataWriter` implementations. + +These tests exercise the AWS SQS/SNS and GCP PubSub readers and writers against +real broker infrastructure. They are gated behind the ``PLUGBOARD_MESSAGE_BROKER_TESTS`` +environment variable (default: not run) so the normal CI suite never touches the brokers. +The flag is enabled in the ``test-integration`` job of ``.github/workflows/lint-test.yaml``, +which also injects the ``AWS_*``/``GCP_*`` credentials and resource identifiers provisioned +by ``plugboard-infra``. + +AWS uses the credential chain (``AWS_ACCESS_KEY_ID``/``AWS_SECRET_ACCESS_KEY``), and the GCP +PubSub client uses Application Default Credentials (``GOOGLE_APPLICATION_CREDENTIALS``). + +The write side is driven through each writer's own ``_convert``/``_send`` pair - the exact +broker write path - so these tests validate the broker wiring and field<->message encoding +round trip, while the generic buffering/batching logic is already covered by the unit tests. + +Hygiene: before each test the queue/subscription is checked, and if it is not empty a +warning is emitted and it is drained; after each test it is drained again so no messages +from a failed run interfere with the next one. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import typing as _t +import warnings + +from aiobotocore.session import get_session as get_boto_session +from google.api_core import exceptions as gcp_exceptions +from google.cloud import pubsub_v1 +import pytest + +from plugboard.connector import AsyncioConnector +from plugboard.library import ( + AWSSNSDataWriter, + AWSSQSDataReader, + GCPPubSubDataReader, + GCPPubSubDataWriter, +) +from plugboard.schemas import ConnectorSpec + + +# Gate the whole module behind an opt-in env var (default: skip). The message broker +# integration tests only run when CI has provisioned the real broker infra. +requires_broker = pytest.mark.skipif( + os.environ.get("PLUGBOARD_MESSAGE_BROKER_TESTS", "").lower() not in ("1", "true", "yes"), + reason="set PLUGBOARD_MESSAGE_BROKER_TESTS=1 to run message broker integration tests", +) + +FIELD_NAMES = ["x", "y"] +# Enough to verify batch delivery, but small so a broken broker fails fast. +RECORDS = [{"x": i, "y": f"value-{i}"} for i in range(1, 6)] +OTHER_JOB_RECORDS = [{"x": 999, "y": "other-job"}] +EXPECTED = sorted((record["x"], record["y"]) for record in RECORDS) +_LOG = logging.getLogger(__name__) + +# The infra sets the SQS visibility timeout to 30s, so a message a concurrent test received +# but has not deleted is redelivered within ~30s and picked up by a later drain pass. The +# drain declares the queue empty only after several consecutive long-poll timeouts, which +# also absorbs staggered SNS->SQS fan-out delivery. +_DRAIN_TIMEOUT = 90.0 +_DRAIN_EMPTY_POLLS = 3 # consecutive empty polls before declaring the queue drained +_DRAIN_POLL_WAIT = 2 # seconds per SQS long poll (also a natural settle between polls) +_DEPTH_CHECK_RETRIES = 3 +_DEPTH_CHECK_INTERVAL = 2.0 +_READ_TIMEOUT = 60.0 + + +def _broker_env_keys() -> list[str]: + """The env vars each broker needs, so a missing one is a clear skip, not a crash.""" + aws = ("AWS_SQS_QUEUE_URL", "AWS_SNS_TOPIC_ARN", "AWS_REGION") + gcp = ("GCP_TOPIC_ID", "GCP_SUBSCRIPTION_ID", "GCP_PUBSUB_PROJECT_ID") + return list(aws) + list(gcp) + + +@pytest.fixture +def broker_env() -> _t.Iterator[None]: + """Skips if any broker identifier is missing, so a partial setup is a clear skip.""" + missing = [key for key in _broker_env_keys() if not os.environ.get(key)] + if missing: + pytest.skip(f"missing broker env vars: {', '.join(missing)}") + yield + + +async def _aws_queue_depth(queue_url: str, region: str) -> int: + """Reads the number of messages in the queue (visible + in-flight).""" + async with get_boto_session().create_client("sqs", region_name=region) as client: + resp = await client.get_queue_attributes( + QueueUrl=queue_url, + AttributeNames=["ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"], + ) + attrs = resp["Attributes"] + return int(attrs["ApproximateNumberOfMessages"]) + int( + attrs["ApproximateNumberOfMessagesNotVisible"] + ) + + +async def _drain_aws_queue(queue_url: str, region: str, *, timeout: float = _DRAIN_TIMEOUT) -> int: + """Drains an SQS queue (purge, then receive-and-delete) and returns the count deleted. + + Declares the queue empty only after several consecutive long-poll timeouts, so staggered + SNS->SQS delivery (or messages still becoming visible) does not cause a premature stop. + """ + deleted = 0 + empty_polls = 0 + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + async with get_boto_session().create_client("sqs", region_name=region) as client: + with contextlib.suppress(Exception): + await client.purge_queue(QueueUrl=queue_url) + while loop.time() < deadline: + resp = await client.receive_message( + QueueUrl=queue_url, + MaxNumberOfMessages=10, + WaitTimeSeconds=_DRAIN_POLL_WAIT, + ) + messages = resp.get("Messages") or [] + if messages: + await client.delete_message_batch( + QueueUrl=queue_url, + Entries=[ + {"Id": str(i), "ReceiptHandle": msg["ReceiptHandle"]} + for i, msg in enumerate(messages) + ], + ) + deleted += len(messages) + empty_polls = 0 + elif empty_polls + 1 >= _DRAIN_EMPTY_POLLS: + return deleted + else: + empty_polls += 1 + return deleted + + +async def _assert_aws_queue_empty(queue_url: str, region: str) -> None: + """Retries the depth check to absorb SQS approximate-count eventual consistency.""" + depth = await _aws_queue_depth(queue_url, region) + for _ in range(_DEPTH_CHECK_RETRIES): + if depth == 0: + return + await asyncio.sleep(_DEPTH_CHECK_INTERVAL) + depth = await _aws_queue_depth(queue_url, region) + pytest.fail(f"SQS queue {queue_url} not empty after drain (depth={depth})") + + +def _gcp_sub_path(project_id: str, subscription_id: str) -> str: + """The fully-qualified PubSub subscription path.""" + return f"projects/{project_id}/subscriptions/{subscription_id}" + + +def _gcp_pull_one(project_id: str, subscription_id: str, *, timeout: float) -> list[object]: + """Pulls up to one batch of messages, returning an empty list if the window elapses. + + Uses only ``pubsub.subscriptions.pull`` (the subscriber role). An empty subscription + raises ``DeadlineExceeded`` once the pull window elapses; that is reported as "none + available" rather than a broker error, while any other error propagates so an + auth/permission problem is not mistaken for an empty broker during setup. + """ + subscriber = pubsub_v1.SubscriberClient() + try: + response = subscriber.pull( + request={ + "subscription": _gcp_sub_path(project_id, subscription_id), + "max_messages": 10, + }, + timeout=timeout, + ) + return list(response.received_messages) + except gcp_exceptions.DeadlineExceeded: + return [] + finally: + subscriber.close() + + +def _gcp_subscription_undelivered(project_id: str, subscription_id: str) -> bool: + """Returns True if the subscription has any available (undelivered) messages.""" + return len(_gcp_pull_one(project_id, subscription_id, timeout=2.0)) > 0 + + +def _drain_gcp_subscription(project_id: str, subscription_id: str, *, timeout: float = 2.0) -> int: + """Pulls and acknowledges all available messages on the subscription; returns the count.""" + subscriber = pubsub_v1.SubscriberClient() + acked = 0 + try: + while True: + response = subscriber.pull( + request={ + "subscription": _gcp_sub_path(project_id, subscription_id), + "max_messages": 10, + }, + timeout=timeout, + ) + ack_ids = [msg.ack_id for msg in response.received_messages] + if not ack_ids: + return acked + subscriber.acknowledge( + request={ + "subscription": _gcp_sub_path(project_id, subscription_id), + "ack_ids": ack_ids, + } + ) + acked += len(ack_ids) + except gcp_exceptions.DeadlineExceeded: + return acked + finally: + subscriber.close() + + +def _log_leftover(message: str, *, pre_test: bool) -> None: + """Reports a leftover message, escalating to a warning (and a pytest warning) pre-test.""" + if pre_test: + warnings.warn(message, stacklevel=3) + _LOG.warning(message) + else: + _LOG.info(message) + + +async def _clean_broker( + *, + pre_test: bool, + queue_url: _t.Optional[str] = None, + region: _t.Optional[str] = None, + project_id: _t.Optional[str] = None, + subscription_id: _t.Optional[str] = None, +) -> None: + """Ensures a broker is empty. + + In ``pre_test`` mode a non-empty broker emits a warning and is drained, then the empty + state is asserted (a leftover that cannot be drained fails the test). In post-test mode + it is drained unconditionally so a failed run leaves nothing for the next one. + """ + if queue_url and region: + depth = await _aws_queue_depth(queue_url, region) + if depth: + _log_leftover( + f"SQS queue {queue_url} was not empty ({depth} message(s)); draining", + pre_test=pre_test, + ) + await _drain_aws_queue(queue_url, region) + if pre_test: + await _assert_aws_queue_empty(queue_url, region) + + if project_id and subscription_id: + drained = _drain_gcp_subscription(project_id, subscription_id) + if drained: + _log_leftover( + f"PubSub subscription {subscription_id} had {drained} leftover message(s); drained", + pre_test=pre_test, + ) + + +@requires_broker +async def test_aws_sns_sqs_round_trip(broker_env: None, job_id_ctx: str) -> None: + """Writes records to the SNS topic and reads them back from the linked SQS queue.""" + queue_url = os.environ["AWS_SQS_QUEUE_URL"] + topic_arn = os.environ["AWS_SNS_TOPIC_ARN"] + region = os.environ["AWS_REGION"] + + await _clean_broker(pre_test=True, queue_url=queue_url, region=region) + + writer = AWSSNSDataWriter( + name="aws-sns-writer", + field_names=FIELD_NAMES, + topic_arn=topic_arn, + region=region, + parse_json=True, + ) + other_writer = AWSSNSDataWriter( + name="aws-sns-other-writer", + field_names=FIELD_NAMES, + topic_arn=topic_arn, + region=region, + parse_json=True, + job_id="Job_other00000000", + ) + reader = AWSSQSDataReader( + name="aws-sqs-reader", + field_names=FIELD_NAMES, + queue_url=queue_url, + region=region, + parse_json=True, + wait_time_seconds=2, + chunk_size=10, + ) + assert writer.job_id == reader.job_id == job_id_ctx + + writer_channels = await connect_writer(writer) + other_channels = await connect_writer(other_writer) + await reader.init() + await writer.init() + await other_writer.init() + received: list[dict[str, _t.Any]] = [] + try: + await write_records(other_writer, other_channels, OTHER_JOB_RECORDS) + await write_records(writer, writer_channels, RECORDS) + received = await read_records(reader, len(RECORDS)) + assert sorted((record["x"], record["y"]) for record in received) == EXPECTED + # The reader acknowledged every record, so nothing should remain on the queue. + await _assert_aws_queue_empty(queue_url, region) + finally: + await other_writer.destroy() + await writer.destroy() + await reader.destroy() + # Safety net: if the read or assertion failed mid-way, drain any unacked + # leftovers so they do not interfere with the next run. + await _clean_broker(pre_test=False, queue_url=queue_url, region=region) + + +@requires_broker +async def test_gcp_pubsub_round_trip(broker_env: None, job_id_ctx: str) -> None: + """Writes records to the PubSub topic and reads them back from the linked subscription.""" + project_id = os.environ["GCP_PUBSUB_PROJECT_ID"] + topic_id = os.environ["GCP_TOPIC_ID"] + subscription_id = os.environ["GCP_SUBSCRIPTION_ID"] + + await _clean_broker(pre_test=True, project_id=project_id, subscription_id=subscription_id) + + writer = GCPPubSubDataWriter( + name="gcp-pubsub-writer", + field_names=FIELD_NAMES, + topic_id=topic_id, + project_id=project_id, + parse_json=True, + ) + other_writer = GCPPubSubDataWriter( + name="gcp-pubsub-other-writer", + field_names=FIELD_NAMES, + topic_id=topic_id, + project_id=project_id, + parse_json=True, + job_id="Job_other00000000", + ) + reader = GCPPubSubDataReader( + name="gcp-pubsub-reader", + field_names=FIELD_NAMES, + subscription_id=subscription_id, + project_id=project_id, + parse_json=True, + chunk_size=10, + pull_timeout=3, + ) + assert writer.job_id == reader.job_id == job_id_ctx + + writer_channels = await connect_writer(writer) + other_channels = await connect_writer(other_writer) + await reader.init() + await writer.init() + await other_writer.init() + received: list[dict[str, _t.Any]] = [] + try: + await write_records(other_writer, other_channels, OTHER_JOB_RECORDS) + await write_records(writer, writer_channels, RECORDS) + received = await read_records(reader, len(RECORDS)) + assert sorted((record["x"], record["y"]) for record in received) == EXPECTED + # The reader acknowledged every record, so the subscription should already be + # empty. Checked before the finally cleanup drains it, so leftover messages are + # caught rather than silently removed. + assert _gcp_subscription_undelivered(project_id, subscription_id) == 0, ( + "PubSub subscription not empty after the round trip" + ) + finally: + await other_writer.destroy() + await writer.destroy() + await reader.destroy() + # Clean up so nothing interferes with the next run. + await _clean_broker(pre_test=False, project_id=project_id, subscription_id=subscription_id) + + +async def connect_writer( + writer: AWSSNSDataWriter | GCPPubSubDataWriter, +) -> dict[str, AsyncioConnector]: + """Connects an input channel to each of the writer's fields. + + Inputs must be connected before the writer is initialised, mirroring the unit tests. + """ + channels = { + field: AsyncioConnector( + spec=ConnectorSpec(source="none.none", target=f"{writer.name}.{field}") + ) + for field in writer.io.inputs + } + await writer.io.connect(list(channels.values())) + return channels + + +async def write_records( + writer: AWSSNSDataWriter | GCPPubSubDataWriter, + channels: dict[str, AsyncioConnector], + records: list[dict[str, _t.Any]], +) -> None: + """Publishes records through the writer's public `Component` lifecycle. + + Each record is sent to the connected input channels and the writer stepped; closing + and running the writer then flushes the remaining buffer to the broker, so the real + ``step``/``run``/``_save`` path (not the private broker methods) is exercised. + """ + senders = {field: await channel.connect_send() for field, channel in channels.items()} + for record in records: + for field, value in record.items(): + await senders[field].send(value) + await writer.step() + await writer.io.close() + await writer.run() + + +async def read_records( + reader: AWSSQSDataReader | GCPPubSubDataReader, + expected: int, + timeout: float = _READ_TIMEOUT, +) -> list[dict[str, _t.Any]]: + """Steps the reader until ``expected`` records are collected or ``timeout`` elapses. + + A reader waits for the next message rather than closing on an empty poll, so an absent + message surfaces as a timeout here instead of a silent pass. + """ + collected: list[dict[str, _t.Any]] = [] + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while len(collected) < expected: + if loop.time() > deadline: + pytest.fail(f"read {len(collected)}/{expected} records before timeout") + await reader.step() + collected.append({field: getattr(reader, field) for field in FIELD_NAMES}) + return collected diff --git a/tests/unit/test_aws_messaging_io.py b/tests/unit/test_aws_messaging_io.py new file mode 100644 index 00000000..0268df72 --- /dev/null +++ b/tests/unit/test_aws_messaging_io.py @@ -0,0 +1,548 @@ +"""Unit tests for the AWS SQS/SNS message data implementations.""" + +from __future__ import annotations + +from collections import deque +import contextlib +import json +import typing as _t +from unittest.mock import AsyncMock, MagicMock, call + +from botocore.exceptions import BotoCoreError, ClientError, TokenRetrievalError +import pytest + +from plugboard.exceptions import ( + MessageBrokerConnectionError, + MessageBrokerPermanentError, + MessageBrokerTransientError, + NoMoreDataException, +) +from plugboard.library import aws_messaging_io +from plugboard.library.aws_messaging_io import AWSSNSDataWriter, AWSSQSDataReader +from plugboard.utils.settings import Settings +from tests import conftest + + +def client_error(code: str, operation: str = "ReceiveMessage") -> ClientError: + """Builds a botocore `ClientError` carrying the given AWS error code.""" + return ClientError({"Error": {"Code": code, "Message": code}}, operation) + + +@contextlib.asynccontextmanager +async def _managed(client: MagicMock, closed: list[str]) -> _t.AsyncIterator[MagicMock]: + """Yields `client` and records that its context was exited.""" + try: + yield client + finally: + closed.append(getattr(client, "service_name", "client")) + + +@pytest.fixture +def aws_client(monkeypatch: pytest.MonkeyPatch) -> _t.Iterator[MagicMock]: + """Replaces the aiobotocore session with one handing out a single client double. + + The double's operations are `AsyncMock`s, so the call arguments are the observable + behaviour of the implementation. + """ + client = MagicMock(name="aws_client") + client.receive_message = AsyncMock(return_value={}) + client.delete_message_batch = AsyncMock(return_value={}) + client.publish = AsyncMock(return_value={"MessageId": "m1"}) + closed: list[str] = [] + created: list[tuple[str, dict[str, _t.Any]]] = [] + + def _create_client(service_name: str, **kwargs: _t.Any) -> _t.Any: + created.append((service_name, kwargs)) + client.service_name = service_name + return _managed(client, closed) + + session = MagicMock(name="session") + session.create_client.side_effect = _create_client + monkeypatch.setattr(aws_messaging_io, "get_session", lambda: session) + client.created = created + client.closed = closed + yield client + + +DEFAULT_JOB_ID = "Job_test12345678" + + +def make_reader(**kwargs: _t.Any) -> AWSSQSDataReader: + """Builds a reader for the fixed test queue.""" + kwargs.setdefault("field_names", ["x", "y"]) + kwargs.setdefault("job_id", DEFAULT_JOB_ID) + return AWSSQSDataReader( + name="sqs-reader", + queue_url="https://sqs.test/1/messages", + region="eu-west-1", + **kwargs, + ) + + +def make_writer(**kwargs: _t.Any) -> AWSSNSDataWriter: + """Builds a writer for the fixed test topic.""" + kwargs.setdefault("field_names", ["x", "y"]) + kwargs.setdefault("job_id", DEFAULT_JOB_ID) + return AWSSNSDataWriter( + name="sns-writer", + topic_arn="arn:aws:sns:eu-west-1:1:messages", + region="eu-west-1", + **kwargs, + ) + + +def sqs_message( + body: str, handle: str, *, job_id: _t.Optional[str] = DEFAULT_JOB_ID +) -> dict[str, _t.Any]: + """Builds an SQS message dict as returned by `ReceiveMessage`.""" + msg: dict[str, _t.Any] = {"Body": body, "ReceiptHandle": handle, "MessageId": handle} + if job_id is not None: + msg["MessageAttributes"] = {"job_id": {"DataType": "String", "StringValue": job_id}} + return msg + + +async def test_reader_connects_sqs_client_in_region(aws_client: MagicMock) -> None: + """Tests that the client is created for SQS in the configured region.""" + reader = make_reader() + await reader._connect() + assert aws_client.created == [("sqs", {"region_name": "eu-west-1"})] + + +async def test_reader_disconnect_exits_client_context(aws_client: MagicMock) -> None: + """Tests that teardown closes the client rather than dropping the reference.""" + reader = make_reader() + await reader._connect() + await reader._disconnect() + assert aws_client.closed == ["sqs"] + + +async def test_reader_receives_with_long_polling(aws_client: MagicMock) -> None: + """Tests the receive request carries the queue, batch size and wait time.""" + aws_client.receive_message.return_value = {"Messages": [sqs_message('{"x": 1}', "h1")]} + reader = make_reader(chunk_size=5, wait_time_seconds=7) + await reader._connect() + + messages = await reader._receive() + + aws_client.receive_message.assert_awaited_once_with( + QueueUrl="https://sqs.test/1/messages", + MaxNumberOfMessages=5, + WaitTimeSeconds=7, + MessageAttributeNames=["job_id"], + ) + assert [msg["ReceiptHandle"] for msg in messages] == ["h1"] + + +async def test_reader_filters_messages_by_job_id_and_acks_unmatched( + aws_client: MagicMock, +) -> None: + """Tests that only messages matching `job_id` are returned and others are discarded.""" + sns_matching = { + "Body": json.dumps( + { + "Type": "Notification", + "Message": '{"x": 2}', + "MessageAttributes": {"job_id": {"Type": "String", "Value": DEFAULT_JOB_ID}}, + } + ), + "ReceiptHandle": "h-sns-match", + "MessageId": "h-sns-match", + } + sns_wrong = { + "Body": json.dumps( + { + "Type": "Notification", + "Message": '{"x": 99}', + "MessageAttributes": {"job_id": {"Type": "String", "Value": "Job_other"}}, + } + ), + "ReceiptHandle": "h-sns-wrong", + "MessageId": "h-sns-wrong", + } + aws_client.receive_message.return_value = { + "Messages": [ + sqs_message('{"x": 1}', "h-direct-match", job_id=DEFAULT_JOB_ID), + sqs_message('{"x": 98}', "h-direct-wrong", job_id="Job_other"), + sqs_message('{"x": 97}', "h-missing", job_id=None), + sns_matching, + sns_wrong, + ] + } + reader = make_reader() + await reader._connect() + + messages = await reader._receive() + + assert [msg["ReceiptHandle"] for msg in messages] == ["h-direct-match", "h-sns-match"] + aws_client.delete_message_batch.assert_awaited_once_with( + QueueUrl="https://sqs.test/1/messages", + Entries=[ + {"Id": "0", "ReceiptHandle": "h-direct-wrong"}, + {"Id": "1", "ReceiptHandle": "h-missing"}, + {"Id": "2", "ReceiptHandle": "h-sns-wrong"}, + ], + ) + + +async def test_reader_discard_failure_wraps_transient_error(aws_client: MagicMock) -> None: + """Tests that a failed discard of an unmatched message surfaces as retryable.""" + aws_client.receive_message.return_value = { + "Messages": [sqs_message('{"x": 99}', "h-wrong", job_id="Job_other")] + } + aws_client.delete_message_batch.side_effect = client_error("ThrottlingException") + reader = make_reader() + await reader._connect() + + with pytest.raises(MessageBrokerTransientError): + await reader._receive() + + +def test_extract_job_id_handles_malformed_attributes() -> None: + """Tests that malformed message attributes or bodies resolve to `None` cleanly.""" + assert aws_messaging_io._extract_job_id({}) is None + assert aws_messaging_io._extract_job_id({"MessageAttributes": {"job_id": "bad"}}) is None + assert aws_messaging_io._extract_job_id({"Body": 123}) is None + assert aws_messaging_io._extract_job_id({"Body": '{"Type": "Notification"}'}) is None + + +async def test_reader_caps_batch_at_the_sqs_maximum(aws_client: MagicMock) -> None: + """Tests that a larger `chunk_size` is capped, since SQS delivers at most 10.""" + reader = make_reader(chunk_size=25) + await reader._connect() + await reader._receive() + assert aws_client.receive_message.await_args.kwargs["MaxNumberOfMessages"] == 10 + + +def test_reader_warns_when_capping_chunk_size(capsys: pytest.CaptureFixture[str]) -> None: + """Tests that capping `chunk_size` is reported rather than applied silently.""" + make_reader(chunk_size=25) + assert "chunk_size is capped" in capsys.readouterr().out + + +async def test_reader_empty_poll_returns_no_messages(aws_client: MagicMock) -> None: + """Tests that an empty long poll reads as 'nothing yet', not as an exhausted queue.""" + aws_client.receive_message.return_value = {} + reader = make_reader() + await reader._connect() + assert await reader._receive() == [] + + +async def test_reader_converts_json_bodies() -> None: + """Tests that JSON message bodies become field buffers.""" + reader = make_reader() + batch = [sqs_message('{"x": 1, "y": "a"}', "h1"), sqs_message('{"x": 2, "y": "b"}', "h2")] + assert await reader._convert(batch) == {"x": deque([1, 2]), "y": deque(["a", "b"])} + + +async def test_reader_non_json_body_uses_data_field() -> None: + """Tests that a raw body is exposed through the `data` field.""" + reader = make_reader(field_names=["data"], parse_json=False) + converted = await reader._convert([sqs_message("hello", "h1")]) + assert converted == {"data": deque(["hello"])} + + +async def test_reader_unwraps_sns_envelope() -> None: + """Tests that an SNS notification envelope delivers the nested record.""" + reader = make_reader() + envelope = json.dumps( + { + "Type": "Notification", + "MessageId": "abc-123", + "Message": json.dumps({"x": 1, "y": "a"}), + "TopicArn": "arn:aws:sns:us-east-1:123:topic", + } + ) + batch = [sqs_message(envelope, "h1")] + assert await reader._convert(batch) == {"x": deque([1]), "y": deque(["a"])} + + +def test_decode_body_raw_string_is_exposed_under_data() -> None: + """Tests that a non-JSON body is exposed under `data` rather than raising.""" + decoded = aws_messaging_io._decode_body("just some text", parse_json=True) + assert decoded == {"data": "just some text"} + + +def test_decode_body_envelope_with_non_json_message_is_exposed_under_data() -> None: + """Tests that an SNS envelope whose Message is a plain string is unwrapped to `data`.""" + envelope = json.dumps({"Type": "Notification", "Message": "plain payload"}) + assert aws_messaging_io._decode_body(envelope, parse_json=True) == {"data": "plain payload"} + + +@pytest.mark.parametrize( + ("body", "value"), + [ + ("42", 42), + ('"hello"', "hello"), + ("true", True), + ("[1, 2, 3]", [1, 2, 3]), + ], +) +def test_decode_body_json_scalar_or_array_is_exposed_under_data(body: str, value: _t.Any) -> None: + """Tests that a non-object JSON payload is wrapped under `data`.""" + assert aws_messaging_io._decode_body(body, parse_json=True) == {"data": value} + + +def test_decode_body_without_parse_json_is_raw() -> None: + """Tests that `parse_json=False` exposes the raw body under `data`.""" + assert aws_messaging_io._decode_body('{"x": 1}', parse_json=False) == {"data": '{"x": 1}'} + + +async def test_reader_deletes_processed_messages(aws_client: MagicMock) -> None: + """Tests that acking deletes by receipt handle, in one batch.""" + reader = make_reader() + await reader._connect() + messages = [sqs_message('{"x": 1}', "h1"), sqs_message('{"x": 2}', "h2")] + + await reader._ack(messages) + + aws_client.delete_message_batch.assert_awaited_once_with( + QueueUrl="https://sqs.test/1/messages", + Entries=[ + {"Id": "0", "ReceiptHandle": "h1"}, + {"Id": "1", "ReceiptHandle": "h2"}, + ], + ) + + +async def test_reader_splits_deletes_into_batch_size_chunks(aws_client: MagicMock) -> None: + """Tests that more than 10 messages are deleted over several batch calls.""" + reader = make_reader() + await reader._connect() + messages = [sqs_message('{"x": 1}', f"h{index}") for index in range(12)] + + await reader._ack(messages) + + calls = aws_client.delete_message_batch.await_args_list + assert [len(call.kwargs["Entries"]) for call in calls] == [10, 2] + assert calls[1].kwargs["Entries"][0]["ReceiptHandle"] == "h10" + + +async def test_reader_skips_messages_without_receipt_handle(aws_client: MagicMock) -> None: + """Tests that a message with no receipt handle is not sent to the delete API.""" + reader = make_reader() + await reader._connect() + await reader._ack([{"Body": '{"x": 1}'}]) + aws_client.delete_message_batch.assert_not_awaited() + + +async def test_reader_reports_failed_deletes_as_transient(aws_client: MagicMock) -> None: + """Tests that a partially failed delete retries instead of claiming success.""" + aws_client.delete_message_batch.return_value = { + "Errors": [{"Id": "0", "Code": "ReceiptHandleIsInvalid"}] + } + reader = make_reader() + await reader._connect() + + with pytest.raises(MessageBrokerTransientError, match="Failed to delete 1"): + await reader._ack([sqs_message('{"x": 1}', "h1")]) + + +async def test_reader_ack_without_connection_raises_connection_error(aws_client: MagicMock) -> None: + """Tests that acking before connecting fails loudly.""" + reader = make_reader() + with pytest.raises(MessageBrokerConnectionError): + await reader._ack([sqs_message('{"x": 1}', "h1")]) + + +async def test_reader_ack_wraps_transient_delete_errors(aws_client: MagicMock) -> None: + """Tests that a throttled delete is wrapped for retry rather than leaking the SDK error.""" + aws_client.delete_message_batch.side_effect = client_error("ThrottlingException") + reader = make_reader() + await reader._connect() + + with pytest.raises(MessageBrokerTransientError): + await reader._ack([sqs_message('{"x": 1}', "h1")]) + + +async def test_reader_without_connection_raises_connection_error(aws_client: MagicMock) -> None: + """Tests that receiving before connecting fails loudly.""" + reader = make_reader() + with pytest.raises(MessageBrokerConnectionError): + await reader._receive() + + +@pytest.mark.parametrize( + ("code", "expected"), + [ + ("AWS.SimpleQueueService.NonExistentQueue", NoMoreDataException), + ("AccessDenied", MessageBrokerPermanentError), + ("InvalidAddress", MessageBrokerPermanentError), + ("ThrottlingException", MessageBrokerTransientError), + ("InternalError", MessageBrokerTransientError), + ], +) +async def test_reader_maps_aws_error_codes( + aws_client: MagicMock, code: str, expected: type[Exception] +) -> None: + """Tests that AWS error codes land on the right broker exception type.""" + aws_client.receive_message.side_effect = client_error(code) + reader = make_reader() + await reader._connect() + + with pytest.raises(expected): + await reader._receive() + + +async def test_reader_transport_error_is_transient(aws_client: MagicMock) -> None: + """Tests that a connection-level failure is retryable.""" + aws_client.receive_message.side_effect = BotoCoreError() + reader = make_reader() + await reader._connect() + + with pytest.raises(MessageBrokerTransientError): + await reader._receive() + + +async def test_reader_credential_expiry_is_transient_for_reconnect(aws_client: MagicMock) -> None: + """Tests that a credential-expiry error stays retryable so reconnects can heal it. + + botocore raises `TokenRetrievalError` when a profile's credentials cannot be + refreshed, for example an expired IAM Identity Center (SSO) session. Reconnects + re-run credential resolution, so the error must remain retryable: a refreshable + provider recovers, and a genuinely dead session exhausts the retries and surfaces + botocore's own message. + """ + aws_client.receive_message.side_effect = TokenRetrievalError( + provider="sso", error_msg="SSO session expired" + ) + reader = make_reader() + await reader._connect() + + with pytest.raises(MessageBrokerTransientError): + await reader._receive() + + +async def test_reader_unclassified_error_propagates(aws_client: MagicMock) -> None: + """Tests that an unknown error is not rewritten into a broker exception.""" + aws_client.receive_message.side_effect = ValueError("unexpected") + reader = make_reader() + await reader._connect() + + with pytest.raises(ValueError, match="unexpected"): + await reader._receive() + + +async def test_writer_connects_sns_client(aws_client: MagicMock) -> None: + """Tests that the writer creates a client for SNS in the configured region.""" + writer = make_writer() + await writer._connect() + assert aws_client.created == [("sns", {"region_name": "eu-west-1"})] + + +async def test_writer_publishes_each_message(aws_client: MagicMock) -> None: + """Tests that every encoded record is published to the topic.""" + writer = make_writer() + await writer._connect() + + await writer._send(['{"x": 1}', '{"x": 2}']) + + assert aws_client.publish.await_args_list == [ + call( + TopicArn="arn:aws:sns:eu-west-1:1:messages", + Message='{"x": 1}', + MessageAttributes={"job_id": {"DataType": "String", "StringValue": DEFAULT_JOB_ID}}, + ), + call( + TopicArn="arn:aws:sns:eu-west-1:1:messages", + Message='{"x": 2}', + MessageAttributes={"job_id": {"DataType": "String", "StringValue": DEFAULT_JOB_ID}}, + ), + ] + + +async def test_writer_converts_fields_to_json_strings() -> None: + """Tests that buffered fields become one JSON message per record.""" + writer = make_writer() + converted = await writer._convert({"x": deque([1, 2]), "y": deque(["a", "b"])}) + assert converted == ['{"x": 1, "y": "a"}', '{"x": 2, "y": "b"}'] + + +async def test_writer_non_json_sends_first_field() -> None: + """Tests that a raw writer publishes the first field's value.""" + writer = make_writer(field_names=["x"], parse_json=False) + assert await writer._convert({"x": deque([7])}) == ["7"] + + +async def test_writer_missing_topic_is_permanent(aws_client: MagicMock) -> None: + """Tests that a deleted topic is not treated as an exhausted source.""" + aws_client.publish.side_effect = client_error("NotFound", "Publish") + writer = make_writer() + await writer._connect() + + with pytest.raises(MessageBrokerPermanentError): + await writer._send(['{"x": 1}']) + + +async def test_writer_disconnect_exits_client_context(aws_client: MagicMock) -> None: + """Tests that teardown closes the client.""" + writer = make_writer() + await writer._connect() + await writer._disconnect() + assert aws_client.closed == ["sns"] + + +async def test_writer_without_connection_raises_connection_error(aws_client: MagicMock) -> None: + """Tests that sending before connecting fails loudly.""" + writer = make_writer() + with pytest.raises(MessageBrokerConnectionError): + await writer._send(["one"]) + + +async def test_region_falls_back_to_settings(aws_client: MagicMock) -> None: + """Tests that `AWS_REGION` supplies the client's region when none is passed.""" + settings = Settings.model_validate({"aws": {"region": "ap-south-1"}}) + with conftest.override_settings(settings): + reader = AWSSQSDataReader( + name="sqs-reader", queue_url="https://sqs.test/1/messages", field_names=["x"] + ) + await reader._connect() + assert aws_client.created == [("sqs", {"region_name": "ap-south-1"})] + await reader._disconnect() + + +async def test_explicit_region_wins_over_settings(aws_client: MagicMock) -> None: + """Tests that an explicit region is not overridden by configuration.""" + settings = Settings.model_validate({"aws": {"region": "ap-south-1"}}) + with conftest.override_settings(settings): + reader = AWSSQSDataReader( + name="sqs-reader", + queue_url="https://sqs.test/1/messages", + field_names=["x"], + region="us-west-2", + ) + await reader._connect() + assert aws_client.created == [("sqs", {"region_name": "us-west-2"})] + await reader._disconnect() + + +def test_missing_region_names_the_environment_variable() -> None: + """Tests that an unresolvable region reports what to configure.""" + with conftest.override_settings(Settings.model_validate({})): + with pytest.raises(ValueError, match="AWS_REGION"): + AWSSQSDataReader( + name="sqs-reader", + queue_url="https://sqs.test/1/messages", + field_names=["x"], + region=None, + ) + + +async def test_reader_receives_from_the_configured_queue(aws_client: MagicMock) -> None: + """Tests that the reader receives from the queue it was configured with.""" + aws_client.receive_message.return_value = {"Messages": []} + reader = make_reader() + await reader._connect() + await reader._receive() + aws_client.receive_message.assert_awaited_once() + assert aws_client.receive_message.await_args.kwargs["QueueUrl"] == "https://sqs.test/1/messages" + await reader._disconnect() + + +async def test_writer_publishes_to_the_configured_topic(aws_client: MagicMock) -> None: + """Tests that the writer publishes to the topic it was configured with.""" + writer = make_writer() + await writer._connect() + await writer._send(['{"x": 1}']) + aws_client.publish.assert_awaited_once() + assert aws_client.publish.await_args.kwargs["TopicArn"] == "arn:aws:sns:eu-west-1:1:messages" + await writer._disconnect() diff --git a/tests/unit/test_file_reader_writer.py b/tests/unit/test_file_reader_writer.py index 859a1fcd..b8ebc9f0 100644 --- a/tests/unit/test_file_reader_writer.py +++ b/tests/unit/test_file_reader_writer.py @@ -22,22 +22,28 @@ @pytest.fixture(scope="module", autouse=True) -def mock_s3_server() -> _t.Generator[None, None, None]: +def mock_s3_server(tmp_path_factory: pytest.TempPathFactory) -> _t.Generator[None, None, None]: """Provides a mock S3 server for testing. Required for async tests until this is resolved: https://github.com/aio-libs/aiobotocore/issues/755 + + The AWS env vars are set on a `MonkeyPatch` context so they are restored on + teardown, and the empty config file lives in a pytest temp dir so it is cleaned up. """ server = ThreadedMotoServer(ip_address=S3_IP_ADDRESS, port=S3_PORT) server.start() - if "AWS_SECRET_ACCESS_KEY" not in os.environ: - os.environ["AWS_SECRET_ACCESS_KEY"] = "test-access-key" # noqa: S105 - if "AWS_ACCESS_KEY_ID" not in os.environ: - os.environ["AWS_ACCESS_KEY_ID"] = "test-key-id" - if "AWS_ENDPOINT_URL" not in os.environ: - os.environ["AWS_ENDPOINT_URL"] = S3_URL - - yield + # These tests only ever talk to the local moto server, so they must not pick up a + # developer's real AWS profile: its region changes how S3 validates buckets. + empty_config = tmp_path_factory.mktemp("aws") / "config" + empty_config.touch() + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setenv("AWS_CONFIG_FILE", str(empty_config)) + monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(empty_config)) + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test-access-key") # noqa: S105 + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "test-key-id") + monkeypatch.setenv("AWS_ENDPOINT_URL", S3_URL) + yield server.stop() diff --git a/tests/unit/test_gcp_pubsub_io.py b/tests/unit/test_gcp_pubsub_io.py new file mode 100644 index 00000000..7cb2cbdb --- /dev/null +++ b/tests/unit/test_gcp_pubsub_io.py @@ -0,0 +1,479 @@ +"""Unit tests for the Google Cloud PubSub message data implementations.""" + +from __future__ import annotations + +from collections import deque +import json +import threading +from types import SimpleNamespace +import typing as _t +from unittest.mock import MagicMock, call + +from google.api_core import exceptions as gcp_exceptions +from google.auth.exceptions import RefreshError +import pytest + +from plugboard.exceptions import ( + MessageBrokerConnectionError, + MessageBrokerPermanentError, + MessageBrokerTransientError, + NoMoreDataException, +) +from plugboard.library import gcp_pubsub_io +from plugboard.library.gcp_pubsub_io import GCPPubSubDataReader, GCPPubSubDataWriter +from plugboard.utils.settings import Settings +from tests import conftest + + +DEFAULT_JOB_ID = "Job_test12345678" + + +def make_message(data: bytes, *, job_id: _t.Optional[str] = DEFAULT_JOB_ID) -> MagicMock: + """Builds a PubSub `ReceivedMessage` double carrying `data`.""" + wrapper = MagicMock() + wrapper.message.data = data + wrapper.message.attributes = {"job_id": job_id} if job_id is not None else {} + wrapper.ack_id = f"ack-{data!r}-{job_id}" + return wrapper + + +@pytest.fixture +def subscriber(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Replaces the PubSub subscriber client class with a per-test double. + + The double is synchronous, so any call it records also proves the implementation + left the event loop thread. + """ + client = MagicMock(name="subscriber_client") + client.pull.return_value = MagicMock(received_messages=[]) + factory = MagicMock(name="SubscriberClient", return_value=client) + monkeypatch.setattr( + gcp_pubsub_io, + "pubsub_v1", + SimpleNamespace(SubscriberClient=factory, PublisherClient=MagicMock()), + ) + return client + + +@pytest.fixture +def publisher(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Replaces the PubSub publisher client class with a per-test double.""" + client = MagicMock(name="publisher_client") + factory = MagicMock(name="PublisherClient", return_value=client) + monkeypatch.setattr( + gcp_pubsub_io, + "pubsub_v1", + SimpleNamespace(SubscriberClient=MagicMock(), PublisherClient=factory), + ) + return client + + +def make_reader(**kwargs: _t.Any) -> GCPPubSubDataReader: + """Builds a reader for the fixed test project/subscription.""" + kwargs.setdefault("field_names", ["x", "y"]) + kwargs.setdefault("job_id", DEFAULT_JOB_ID) + return GCPPubSubDataReader( + name="gcp-reader", + project_id="test-project", + subscription_id="test-subscription", + **kwargs, + ) + + +def make_writer(**kwargs: _t.Any) -> GCPPubSubDataWriter: + """Builds a writer for the fixed test project/topic.""" + kwargs.setdefault("field_names", ["x", "y"]) + kwargs.setdefault("job_id", DEFAULT_JOB_ID) + return GCPPubSubDataWriter( + name="gcp-writer", + project_id="test-project", + topic_id="test-topic", + **kwargs, + ) + + +async def test_reader_connect_builds_client_off_the_event_loop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that constructing the subscriber client does not run on the event loop. + + Building the client opens a gRPC channel and starts its threads, which is blocking. + """ + build_threads: list[str] = [] + client = MagicMock(name="subscriber_client") + + def _build(*args: _t.Any, **kwargs: _t.Any) -> MagicMock: + build_threads.append(threading.current_thread().name) + return client + + monkeypatch.setattr( + gcp_pubsub_io, + "pubsub_v1", + SimpleNamespace(SubscriberClient=_build, PublisherClient=MagicMock()), + ) + reader = make_reader() + await reader._connect() + + assert build_threads == [build_threads[0]] + assert build_threads[0] != threading.current_thread().name + + +async def test_reader_pulls_from_subscription_path(subscriber: MagicMock) -> None: + """Tests that a pull asks for the configured subscription and batch size.""" + subscriber.pull.return_value = MagicMock(received_messages=[make_message(b'{"x": 1}')]) + reader = make_reader(chunk_size=5) + await reader._connect() + + messages = await reader._receive() + + subscriber.pull.assert_called_once_with( + request={ + "subscription": "projects/test-project/subscriptions/test-subscription", + "max_messages": 5, + }, + timeout=30.0, + ) + assert len(messages) == 1 + + +async def test_reader_filters_messages_by_job_id_and_acks_unmatched( + subscriber: MagicMock, +) -> None: + """Tests that only messages matching `job_id` are returned and others are acknowledged.""" + matched = make_message(b'{"x": 1}', job_id=DEFAULT_JOB_ID) + wrong_job = make_message(b'{"x": 2}', job_id="Job_other") + no_job = make_message(b'{"x": 3}', job_id=None) + subscriber.pull.return_value = MagicMock(received_messages=[matched, wrong_job, no_job]) + reader = make_reader() + await reader._connect() + + messages = await reader._receive() + + assert messages == [matched] + subscriber.acknowledge.assert_called_once_with( + request={ + "subscription": "projects/test-project/subscriptions/test-subscription", + "ack_ids": [wrong_job.ack_id, no_job.ack_id], + } + ) + + +async def test_reader_discard_failure_wraps_transient_error(subscriber: MagicMock) -> None: + """Tests that a failed discard of an unmatched message surfaces as retryable.""" + subscriber.pull.return_value = MagicMock( + received_messages=[make_message(b'{"x": 99}', job_id="Job_other")] + ) + subscriber.acknowledge.side_effect = gcp_exceptions.DeadlineExceeded("deadline") + reader = make_reader() + await reader._connect() + + with pytest.raises(MessageBrokerTransientError): + await reader._receive() + + +def test_extract_job_id_handles_missing_attributes() -> None: + """Tests that a message wrapper with non-mapping attributes returns `None`.""" + wrapper = SimpleNamespace(message=SimpleNamespace(attributes=None)) + assert gcp_pubsub_io._extract_job_id(wrapper) is None + + +async def test_reader_empty_pull_returns_no_messages(subscriber: MagicMock) -> None: + """Tests that an idle pull reads as 'nothing yet', not as an exhausted source.""" + subscriber.pull.return_value = MagicMock(received_messages=[]) + reader = make_reader() + await reader._connect() + assert await reader._receive() == [] + + +async def test_reader_pulls_off_the_event_loop(subscriber: MagicMock) -> None: + """Tests that the blocking PubSub pull runs in a worker thread. + + `pull` is synchronous gRPC in the real client; if it ran on the event loop it would + stall every other component in the process. + """ + caller_threads: list[str] = [] + + def _pull(request: dict[str, _t.Any], timeout: float) -> MagicMock: + caller_threads.append(threading.current_thread().name) + return MagicMock(received_messages=[]) + + subscriber.pull.side_effect = _pull + reader = make_reader() + await reader._connect() + await reader._receive() + + assert len(caller_threads) == 1 + assert caller_threads[0] != threading.current_thread().name + + +async def test_reader_converts_json_messages() -> None: + """Tests that JSON payloads become field buffers keyed by the declared fields.""" + reader = make_reader() + batch = [make_message(b'{"x": 1, "y": "a"}'), make_message(b'{"x": 2, "y": "b"}')] + assert await reader._convert(batch) == {"x": deque([1, 2]), "y": deque(["a", "b"])} + + +async def test_reader_non_json_messages_fill_single_field() -> None: + """Tests that a raw payload is exposed through the `data` field.""" + reader = make_reader(field_names=["data"], parse_json=False) + converted = await reader._convert([make_message(b"hello")]) + assert converted == {"data": deque([b"hello"])} + + +def test_decode_payload_non_utf8_is_exposed_under_data() -> None: + """Tests that a payload which is not valid UTF-8 is exposed under `data`.""" + assert gcp_pubsub_io._decode_payload(b"\xff\xfe", parse_json=True) == {"data": b"\xff\xfe"} + + +def test_decode_payload_non_json_is_exposed_under_data() -> None: + """Tests that a non-JSON payload is exposed under `data`.""" + assert gcp_pubsub_io._decode_payload(b"not json", parse_json=True) == {"data": b"not json"} + + +@pytest.mark.parametrize("payload", [b"42", b'"hello"', b"true", b"[1, 2]"]) +def test_decode_payload_json_scalar_or_array_is_exposed_under_data(payload: bytes) -> None: + """Tests that a non-object JSON payload is wrapped under `data`.""" + assert gcp_pubsub_io._decode_payload(payload, parse_json=True) == {"data": json.loads(payload)} + + +def test_decode_payload_without_parse_json_is_raw() -> None: + """Tests that `parse_json=False` exposes the raw payload under `data`.""" + assert gcp_pubsub_io._decode_payload(b'{"x": 1}', parse_json=False) == {"data": b'{"x": 1}'} + + +async def test_reader_ack_sends_ack_ids(subscriber: MagicMock) -> None: + """Tests that acknowledging a batch sends exactly its ack ids.""" + reader = make_reader() + await reader._connect() + messages = [make_message(b'{"x": 1}'), make_message(b'{"x": 2}')] + + await reader._ack(messages) + + subscriber.acknowledge.assert_called_once_with( + request={ + "subscription": "projects/test-project/subscriptions/test-subscription", + "ack_ids": [msg.ack_id for msg in messages], + } + ) + + +async def test_reader_ack_without_connection_raises_connection_error() -> None: + """Tests that acking before connecting fails loudly.""" + reader = make_reader() + with pytest.raises(MessageBrokerConnectionError): + await reader._ack([make_message(b'{"x": 1}')]) + + +async def test_reader_ack_wraps_failed_acknowledgments(subscriber: MagicMock) -> None: + """Tests that a failed acknowledgment is wrapped for retry rather than leaking gRPC.""" + subscriber.acknowledge.side_effect = gcp_exceptions.DeadlineExceeded("deadline") + reader = make_reader() + await reader._connect() + + with pytest.raises(MessageBrokerTransientError): + await reader._ack([make_message(b'{"x": 1}')]) + + +async def test_reader_disconnect_closes_subscriber(subscriber: MagicMock) -> None: + """Tests that teardown closes the client, releasing its channel.""" + reader = make_reader() + await reader._connect() + await reader._disconnect() + subscriber.close.assert_called_once_with() + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (gcp_exceptions.NotFound("gone"), NoMoreDataException), + (gcp_exceptions.PermissionDenied("nope"), MessageBrokerPermanentError), + (gcp_exceptions.InvalidArgument("bad"), MessageBrokerPermanentError), + (gcp_exceptions.ServiceUnavailable("busy"), MessageBrokerTransientError), + (RefreshError("refresh token revoked"), MessageBrokerPermanentError), + ], +) +async def test_reader_maps_broker_errors( + subscriber: MagicMock, error: Exception, expected: type[Exception] +) -> None: + """Tests that PubSub failures land on the right broker exception type.""" + subscriber.pull.side_effect = error + reader = make_reader() + await reader._connect() + + with pytest.raises(expected): + await reader._receive() + + +async def test_reader_idle_pull_timeout_returns_no_messages(subscriber: MagicMock) -> None: + """Tests that a pull window elapsing on an empty subscription reads as 'nothing yet'. + + An idle pull raises `DeadlineExceeded`; treating it as a broker error would trigger a + needless reconnect and eventually fail the reader on an empty subscription. + """ + subscriber.pull.side_effect = gcp_exceptions.DeadlineExceeded("late") + reader = make_reader() + await reader._connect() + assert await reader._receive() == [] + + +async def test_reader_unclassified_error_propagates(subscriber: MagicMock) -> None: + """Tests that an unknown error is not rewritten into a broker exception.""" + subscriber.pull.side_effect = ValueError("unexpected") + reader = make_reader() + await reader._connect() + + with pytest.raises(ValueError, match="unexpected"): + await reader._receive() + + +async def test_reader_without_connection_raises_connection_error(subscriber: MagicMock) -> None: + """Tests that receiving before connecting fails loudly rather than silently.""" + reader = make_reader() + with pytest.raises(MessageBrokerConnectionError): + await reader._receive() + + +async def test_reader_missing_project_id(monkeypatch: pytest.MonkeyPatch) -> None: + """Tests that a missing project id reports the environment variable to set.""" + with conftest.override_settings(Settings.model_validate({})): + with pytest.raises(ValueError, match="GCP_PUBSUB_PROJECT_ID"): + GCPPubSubDataReader( + name="gcp-reader", + subscription_id="test-subscription", + field_names=["x"], + project_id=None, + ) + + +async def test_reader_project_id_from_settings(subscriber: MagicMock) -> None: + """Tests that `GCP_PUBSUB_PROJECT_ID` supplies the pull's project when not passed.""" + settings = Settings.model_validate({"gcp_pubsub": {"project_id": "settings-project"}}) + with conftest.override_settings(settings): + reader = GCPPubSubDataReader( + name="gcp-reader", + subscription_id="test-subscription", + field_names=["x"], + ) + await reader._connect() + await reader._receive() + subscriber.pull.assert_called_once() + assert subscriber.pull.call_args.kwargs["request"]["subscription"] == ( + "projects/settings-project/subscriptions/test-subscription" + ) + await reader._disconnect() + + +async def test_writer_publishes_encoded_records(publisher: MagicMock) -> None: + """Tests that each record is published to the topic path as JSON bytes.""" + futures = [MagicMock(), MagicMock()] + publisher.publish.side_effect = futures + writer = make_writer() + await writer._connect() + + await writer._send([b'{"x": 1}', b'{"x": 2}']) + + assert [published.kwargs["data"] for published in publisher.publish.call_args_list] == [ + b'{"x": 1}', + b'{"x": 2}', + ] + assert [published.kwargs["job_id"] for published in publisher.publish.call_args_list] == [ + DEFAULT_JOB_ID, + DEFAULT_JOB_ID, + ] + assert [published.args for published in publisher.publish.call_args_list] == [ + ("projects/test-project/topics/test-topic",) + ] * 2 + assert [future.result.call_args for future in futures] == [ + call(timeout=60.0), + call(timeout=60.0), + ] + + +async def test_writer_waits_off_the_event_loop(publisher: MagicMock) -> None: + """Tests that waiting for broker confirmation does not block the event loop.""" + caller_threads: list[str] = [] + + class _Future: + def result(self, timeout: float) -> None: + caller_threads.append(threading.current_thread().name) + + publisher.publish.return_value = _Future() + writer = make_writer() + await writer._connect() + await writer._send([b"one", b"two"]) + + assert len(caller_threads) == 2 + assert all(name != threading.current_thread().name for name in caller_threads) + + +async def test_writer_converts_fields_to_json_bytes() -> None: + """Tests that buffered fields become one JSON message per record.""" + writer = make_writer() + converted = await writer._convert({"x": deque([1, 2]), "y": deque(["a", "b"])}) + assert converted == [b'{"x": 1, "y": "a"}', b'{"x": 2, "y": "b"}'] + + +async def test_writer_non_json_sends_first_field() -> None: + """Tests that a raw writer publishes the first field's value.""" + writer = make_writer(field_names=["x"], parse_json=False) + assert await writer._convert({"x": deque(["hello"])}) == [b"hello"] + + +async def test_writer_disconnect_stops_publisher(publisher: MagicMock) -> None: + """Tests that teardown stops the publisher so its channel and threads are released.""" + writer = make_writer() + await writer._connect() + await writer._disconnect() + publisher.stop.assert_called_once_with() + + +async def test_writer_disconnect_wraps_stop_errors(publisher: MagicMock) -> None: + """Tests that a failed publisher stop is wrapped rather than leaking gRPC.""" + publisher.stop.side_effect = gcp_exceptions.FailedPrecondition("not stopped") + writer = make_writer() + await writer._connect() + with pytest.raises(MessageBrokerPermanentError): + await writer._disconnect() + + +async def test_writer_publish_failure_maps_error(publisher: MagicMock) -> None: + """Tests that a publish failure surfaces as a broker exception.""" + publisher.publish.side_effect = gcp_exceptions.ServiceUnavailable("busy") + writer = make_writer() + await writer._connect() + + with pytest.raises(MessageBrokerTransientError): + await writer._send([b"one"]) + + +async def test_writer_publish_to_missing_topic_is_permanent(publisher: MagicMock) -> None: + """Tests that publishing to a deleted topic is permanent, not an exhausted source. + + `NoMoreDataException` is reserved for exhausted read sources; a writer cannot recover + from a missing topic, so it must fail as permanent. + """ + publisher.publish.side_effect = gcp_exceptions.NotFound("gone") + writer = make_writer() + await writer._connect() + + with pytest.raises(MessageBrokerPermanentError): + await writer._send([b"one"]) + + +async def test_writer_without_connection_raises_connection_error(publisher: MagicMock) -> None: + """Tests that sending before connecting fails loudly.""" + writer = make_writer() + with pytest.raises(MessageBrokerConnectionError): + await writer._send([b"one"]) + + +async def test_writer_maps_unconfirmed_publish(publisher: MagicMock) -> None: + """Tests that a future that fails on `result` is reported as a broker error.""" + future = MagicMock() + future.result.side_effect = gcp_exceptions.FailedPrecondition("rejected") + publisher.publish.return_value = future + writer = make_writer() + await writer._connect() + + with pytest.raises(MessageBrokerPermanentError): + await writer._send([b"one"]) diff --git a/tests/unit/test_message_data_reader.py b/tests/unit/test_message_data_reader.py new file mode 100644 index 00000000..5a4b4e5c --- /dev/null +++ b/tests/unit/test_message_data_reader.py @@ -0,0 +1,475 @@ +"""Unit tests for the `MessageDataReader` base class.""" + +from __future__ import annotations + +from collections import deque +import typing as _t + +import pytest + +from plugboard.exceptions import ( + IOStreamClosedError, + MessageBrokerConnectionError, + MessageBrokerPermanentError, + MessageBrokerTransientError, + NoMoreDataException, +) +from plugboard.library.message_reader import MessageDataReader +from plugboard.state import DictStateBackend +from plugboard.utils.entities import EntityIdGen +from plugboard.utils.retry import RetryPolicy + + +class ScriptedReader(MessageDataReader): + """A `MessageDataReader` driven by a script of poll outcomes. + + Each `_receive` call consumes the next entry of `script`: a list of records is + delivered, and an exception is raised. Once the script runs out, polls return no + messages - which is how a real idle broker behaves, and must not end the stream. + """ + + def __init__(self, *args: _t.Any, script: list[_t.Any], **kwargs: _t.Any) -> None: + """Instantiates the reader. + + Args: + *args: Positional arguments for + [`MessageDataReader`][plugboard.library.MessageDataReader]. + script: The per-poll outcomes to play back. + **kwargs: Keyword arguments for + [`MessageDataReader`][plugboard.library.MessageDataReader]. + """ + super().__init__(*args, **kwargs) + self.script = script + self.polls = 0 + self.connects = 0 + self.disconnects = 0 + self.acked: list[list[_t.Any]] = [] + + async def _connect(self) -> None: + self.connects += 1 + + async def _disconnect(self) -> None: + self.disconnects += 1 + + async def _receive(self) -> list[_t.Any]: + outcome = self.script[self.polls] if self.polls < len(self.script) else [] + self.polls += 1 + if isinstance(outcome, Exception): + raise outcome + return _t.cast(list[_t.Any], outcome) + + async def _convert(self, data: list[_t.Any]) -> dict[str, deque]: + return {field: deque(record.get(field) for record in data) for field in self.io.outputs} + + async def _ack(self, messages: list[_t.Any]) -> None: + self.acked.append(list(messages)) + + +class UnreconnectableReader(ScriptedReader): + """A reader whose first connect succeeds and every reconnect afterwards fails.""" + + async def _connect(self) -> None: + self.connects += 1 + if self.connects > 1: + raise OSError("broker unreachable") + + +class UndisconnectableReader(ScriptedReader): + """A reader whose disconnect always fails, as it does when the link is already gone.""" + + async def _disconnect(self) -> None: + self.disconnects += 1 + raise OSError("connection already lost") + + +MESSAGES = [ + {"x": 1, "y": "a"}, + {"x": 2, "y": "b"}, + {"x": 3, "y": "c"}, + {"x": 4, "y": "d"}, + {"x": 5, "y": "e"}, +] + + +def make_reader(script: list[_t.Any], *, max_retries: int = 3, **kwargs: _t.Any) -> ScriptedReader: + """Builds a `ScriptedReader` over the `x`/`y` test fields.""" + kwargs.setdefault("field_names", ["x", "y"]) + kwargs.setdefault("topic", "test-topic") + kwargs.setdefault("idle_poll_delay", 0.0) + kwargs.setdefault("retry_policy", RetryPolicy(max_retries=max_retries)) + return ScriptedReader(name="test-reader", script=script, **kwargs) + + +async def drain(reader: ScriptedReader, limit: int = 100) -> list[dict[str, _t.Any]]: + """Steps a reader until its stream closes, collecting the records it published.""" + records: list[dict[str, _t.Any]] = [] + for _ in range(limit): + try: + await reader.step() + except IOStreamClosedError: + return records + records.append({"x": reader.x, "y": reader.y}) # type: ignore[attr-defined] + raise AssertionError(f"Reader did not finish within {limit} steps") + + +async def test_init_connects_and_starts_prefetch() -> None: + """Tests that `init` connects and pre-fetches without waiting for data.""" + reader = make_reader([MESSAGES[:2]]) + await reader.init() + assert reader.connects == 1 + prefetch = reader._task + assert prefetch is not None + # The pre-fetch runs as a task, so `init` never blocks on the broker. + assert await prefetch == MESSAGES[:2] + assert reader.polls == 1 + await reader.destroy() + + +async def test_destroy_disconnects() -> None: + """Tests that `destroy` stops the pre-fetch and disconnects.""" + reader = make_reader([MESSAGES]) + await reader.init() + await reader.step() + await reader.destroy() + assert reader.disconnects == 1 + assert reader._task is None + + +async def test_topic_is_retained() -> None: + """Tests that the topic is kept for broker identification and logging.""" + reader = make_reader([], topic="my-topic") + assert reader._topic == "my-topic" + await reader.destroy() + + +async def test_step_publishes_records_in_order() -> None: + """Tests that records are published one per step, in broker order.""" + reader = make_reader([MESSAGES, NoMoreDataException()]) + await reader.init() + assert await drain(reader) == MESSAGES + await reader.destroy() + + +@pytest.mark.parametrize("chunk_size", [1, 2, 3, 5]) +async def test_reads_across_batch_boundaries(chunk_size: int) -> None: + """Tests that splitting messages over several polls loses nothing.""" + batches = [MESSAGES[i : i + chunk_size] for i in range(0, len(MESSAGES), chunk_size)] + reader = make_reader([*batches, NoMoreDataException()], chunk_size=chunk_size) + await reader.init() + assert await drain(reader) == MESSAGES + await reader.destroy() + + +async def test_missing_fields_read_as_none() -> None: + """Tests that a field absent from a message reads as `None`.""" + reader = make_reader([[{"x": 1}], NoMoreDataException()]) + await reader.init() + await reader.step() + assert reader.x == 1 # type: ignore[attr-defined] + assert reader.y is None # type: ignore[attr-defined] + await reader.destroy() + + +async def test_single_field_reader() -> None: + """Tests a reader with a single output field.""" + reader = ScriptedReader( + name="test-reader", + field_names=["value"], + topic="test-topic", + idle_poll_delay=0.0, + script=[[{"value": 0}, {"value": 1}], NoMoreDataException()], + ) + await reader.init() + values = [] + for _ in range(2): + await reader.step() + values.append(reader.value) # type: ignore[attr-defined] + assert values == [0, 1] + with pytest.raises(IOStreamClosedError): + await reader.step() + await reader.destroy() + + +async def test_empty_poll_waits_for_late_messages() -> None: + """Tests that idle polls do not end the stream, so late messages are still read. + + A reader that starts before its producer must keep running: two empty polls are + followed by a batch, and `step()` returns that record rather than raising. + """ + reader = make_reader([[], [], [{"x": 9, "y": "z"}], NoMoreDataException()]) + await reader.init() + + await reader.step() + + assert (reader.x, reader.y) == (9, "z") # type: ignore[attr-defined] + assert reader.polls >= 3 + assert not reader.io.is_closed + await reader.destroy() + + +async def test_idle_polls_do_not_publish_or_ack() -> None: + """Tests that empty polls neither emit records nor acknowledge anything.""" + reader = make_reader([[], [], MESSAGES[:1], NoMoreDataException()]) + await reader.init() + records = await drain(reader) + assert records == MESSAGES[:1] + assert reader.polls == 4 + assert len(reader.acked) == 1 + await reader.destroy() + + +async def test_no_dangling_task_when_source_exhausted() -> None: + """Tests that exhaustion closes the stream and leaves no task behind.""" + reader = make_reader([NoMoreDataException()]) + await reader.init() + with pytest.raises(IOStreamClosedError): + await reader.step() + assert reader._task is None + await reader.destroy() + + +async def test_batch_acked_only_after_its_last_record_is_consumed() -> None: + """Tests that a batch is acknowledged once, after every record was published. + + Acknowledging earlier would lose the remaining messages on a crash, so this is the + at-least-once guarantee for the batch. + """ + reader = make_reader([MESSAGES[:3], NoMoreDataException()], chunk_size=3) + await reader.init() + + await reader.step() + await reader.step() + assert reader.acked == [] # Two of three records consumed: nothing acked yet. + + await reader.step() # The third record drains the batch. + assert [[msg["x"] for msg in batch] for batch in reader.acked] == [[1, 2, 3]] + await reader.destroy() + + +async def test_every_consumed_record_is_acknowledged_once() -> None: + """Tests that the acked messages are exactly the messages that were published.""" + batches = [MESSAGES[:2], MESSAGES[2:]] + reader = make_reader([*batches, NoMoreDataException()]) + await reader.init() + await drain(reader) + + acked = [msg["x"] for batch in reader.acked for msg in batch] + assert acked == [msg["x"] for msg in MESSAGES] + await reader.destroy() + + +async def test_unconsumed_messages_are_not_acknowledged() -> None: + """Tests that destroying mid-batch leaves the unread messages for redelivery.""" + reader = make_reader([MESSAGES]) + await reader.init() + await reader.step() + await reader.destroy() + assert reader.acked == [] + + +async def test_transient_receive_failure_reconnects_and_recovers( + backoff_delays: list[float], +) -> None: + """Tests that a transient failure is retried after reconnecting.""" + reader = make_reader( + [MessageBrokerTransientError("blip"), MESSAGES[:2], NoMoreDataException()], + max_retries=3, + ) + await reader.init() + assert await drain(reader) == MESSAGES[:2] + assert reader.connects == 2 # Initial connect plus one reconnect. + assert reader.disconnects == 1 + assert backoff_delays == [1.0] # Base delay on the first retry. + await reader.destroy() + + +async def test_retry_exhaustion_raises_last_broker_error(backoff_delays: list[float]) -> None: + """Tests that retries are bounded and the broker's own error is raised.""" + failures = [ConnectionError(f"failure {index}") for index in range(5)] + reader = make_reader(failures, max_retries=2) + await reader.init() + + with pytest.raises(ConnectionError, match="failure 2"): + await reader.step() + + assert reader.polls == 3 # Initial attempt plus two retries. + assert reader.connects == 3 # Initial connect plus one reconnect per retry. + assert reader.disconnects == 2 + assert backoff_delays == [1.0, 2.0] + await reader.destroy() + + +async def test_backoff_delay_is_capped(backoff_delays: list[float]) -> None: + """Tests that exponential backoff stops growing at `max_delay`.""" + policy = RetryPolicy(max_retries=5, base_delay=1.0, max_delay=3.0) + reader = make_reader([ConnectionError("down")] * 6, retry_policy=policy) + await reader.init() + + with pytest.raises(ConnectionError): + await reader.step() + + assert backoff_delays == [1.0, 2.0, 3.0, 3.0, 3.0] + await reader.destroy() + + +@pytest.mark.parametrize( + ("attempt", "expected"), + [(0, 1.0), (1, 2.0), (2, 4.0), (10, 60.0)], +) +def test_retry_policy_delay_for(attempt: int, expected: float) -> None: + """Tests that the backoff formula doubles per attempt and respects the cap.""" + assert RetryPolicy().delay_for(attempt) == expected + + +async def test_permanent_error_is_not_retried() -> None: + """Tests that a permanent broker error propagates after a single attempt.""" + reader = make_reader( + [MessageBrokerPermanentError("access denied"), MESSAGES], + max_retries=3, + ) + await reader.init() + + with pytest.raises(MessageBrokerPermanentError, match="access denied"): + await reader.step() + + assert reader.polls == 1 + assert reader.connects == 1 # No reconnect attempted. + await reader.destroy() + + +async def test_failing_disconnect_still_reconnects() -> None: + """Tests that a broken connection is not mistaken for a reason to give up reconnecting. + + The disconnect during a reconnect is expected to fail when the link already dropped, + so it must not prevent the new connection from being made. + """ + reader = UndisconnectableReader( + name="test-reader", + field_names=["x", "y"], + topic="test-topic", + idle_poll_delay=0.0, + script=[ConnectionError("blip"), MESSAGES[:1], NoMoreDataException()], + retry_policy=RetryPolicy(max_retries=3), + ) + await reader.init() + + assert await drain(reader) == MESSAGES[:1] + assert reader.connects == 2 # Initial connect plus one reconnect. + assert reader.disconnects == 1 + + # A disconnect that fails during teardown is surfaced rather than hidden. + with pytest.raises(OSError, match="connection already lost"): + await reader.destroy() + + +async def test_reconnect_failure_does_not_abort_retries( + backoff_delays: list[float], +) -> None: + """Tests that a failing reconnect skips that attempt but the retries continue. + + During a network partition the reconnect can fail; rather than running the read and + failing with a synthetic "not connected" error, that attempt is skipped, the retries + keep reconnecting, and the error eventually raised is the broker's own. + """ + reader = UnreconnectableReader( + name="test-reader", + field_names=["x", "y"], + topic="test-topic", + idle_poll_delay=0.0, + script=[ConnectionError("read failed")] * 3, + retry_policy=RetryPolicy(max_retries=2), + ) + await reader.init() + + with pytest.raises(ConnectionError, match="read failed"): + await reader.step() + + assert reader.polls == 1 # Only the initial read ran; the retries were skipped. + assert reader.connects == 3 # Initial connect plus one reconnect per retry. + assert backoff_delays == [1.0, 2.0] + await reader.destroy() + + +class _FlakyConnectionReader(ScriptedReader): + """Models a connection that drops and cannot be re-established. + + The first read fails with a real broker error; the link is then torn down and every + reconnect fails, leaving the reader disconnected (where a real receive would raise + `MessageBrokerConnectionError`). + """ + + def __init__(self, *args: _t.Any, script: list[_t.Any], **kwargs: _t.Any) -> None: + super().__init__(*args, script=script, **kwargs) + self.connected = True + + async def _connect(self) -> None: + self.connected = True + self.connects += 1 + + async def _reconnect(self) -> None: + # The link is down and cannot be re-established: the client is left disconnected. + self.connected = False + raise OSError("broker unreachable") + + async def _receive(self) -> list[_t.Any]: + if not self.connected: + raise MessageBrokerConnectionError("client is not connected") + outcome = self.script[self.polls] if self.polls < len(self.script) else [] + self.polls += 1 + if isinstance(outcome, Exception): + raise outcome + return _t.cast(list[_t.Any], outcome) + + +async def test_retries_exhausted_after_failed_reconnect_raise_broker_error( + backoff_delays: list[float], +) -> None: + """Tests that total failure reports the broker's error, not a synthetic one. + + When every reconnect fails the reader is left disconnected, so running a read would + surface `MessageBrokerConnectionError("client is not connected")`. The retries must + instead surface the last real broker error from the attempt that last ran. + """ + reader = _FlakyConnectionReader( + name="test-reader", + field_names=["x", "y"], + topic="test-topic", + idle_poll_delay=0.0, + script=[ConnectionError("broker timeout")], + retry_policy=RetryPolicy(max_retries=2), + ) + await reader.init() + + with pytest.raises(ConnectionError, match="broker timeout") as excinfo: + await reader.step() + + assert "not connected" not in str(excinfo.value) # Never the synthetic connection error. + assert reader.connects == 1 # Only `init` connected; the reconnects all failed. + assert backoff_delays == [1.0, 2.0] + await reader.destroy() + + +async def test_reader_job_id_resolution(job_id_ctx: str) -> None: + """Tests `job_id` precedence: explicit > state > DI context > generated fallback.""" + from_ctx = make_reader([]) + assert from_ctx.job_id == job_id_ctx + + explicit = make_reader([], job_id="Job_explicit123") + assert explicit.job_id == "Job_explicit123" + + state = DictStateBackend(job_id="Job_state1234567") + await state.init() + from_state = make_reader([], state=state) + assert from_state.job_id == "Job_state1234567" + + +def test_reader_job_id_standalone_fallback() -> None: + """Tests that a standalone reader outside a DI context generates a stable `job_id`.""" + reader = make_reader([]) + resolved = reader.job_id + assert EntityIdGen.is_job_id(resolved) + assert reader.job_id == resolved + # Re-resolving inside the component's own context hits the active DI context path. + reader._job_id = None + with reader._job_id_ctx(): + assert EntityIdGen.is_job_id(reader.job_id) diff --git a/tests/unit/test_message_data_writer.py b/tests/unit/test_message_data_writer.py new file mode 100644 index 00000000..b74350f5 --- /dev/null +++ b/tests/unit/test_message_data_writer.py @@ -0,0 +1,395 @@ +"""Unit tests for the `MessageDataWriter` base class and its shared encoding helpers.""" + +from __future__ import annotations + +from collections import deque +import typing as _t + +import pytest + +from plugboard.connector import AsyncioConnector +from plugboard.exceptions import ( + MessageBrokerPermanentError, + MessageBrokerTransientError, +) +from plugboard.library.message_writer import ( + MessageDataWriter, + encode_records, + encode_records_bytes, + iter_records, +) +from plugboard.schemas import ConnectorSpec +from plugboard.state import DictStateBackend +from plugboard.utils.entities import EntityIdGen +from plugboard.utils.retry import RetryPolicy + + +class RecordingWriter(MessageDataWriter): + """A `MessageDataWriter` that records what it sends and can script send failures. + + Each `_send` call consumes the next entry of `script`: an exception is raised, and + anything else is recorded as a delivered batch. When the script runs out, sends + succeed and are recorded. + """ + + def __init__(self, *args: _t.Any, script: list[_t.Any] | None = None, **kwargs: _t.Any) -> None: + """Instantiates the writer. + + Args: + *args: Positional arguments for + [`MessageDataWriter`][plugboard.library.MessageDataWriter]. + script: The per-send outcomes to play back. + **kwargs: Keyword arguments for + [`MessageDataWriter`][plugboard.library.MessageDataWriter]. + """ + super().__init__(*args, **kwargs) + self.script = script or [] + self.sends: list[list[_t.Any]] = [] + self.attempts = 0 + self.connects = 0 + self.disconnects = 0 + + async def _connect(self) -> None: + self.connects += 1 + + async def _disconnect(self) -> None: + self.disconnects += 1 + + async def _convert(self, data: dict[str, deque]) -> list[_t.Any]: + return list(iter_records(data)) + + async def _send(self, messages: list[_t.Any]) -> None: + outcome = self.script[self.attempts] if self.attempts < len(self.script) else None + self.attempts += 1 + if isinstance(outcome, Exception): + raise outcome + self.sends.append(list(messages)) + + +class UnreconnectableWriter(RecordingWriter): + """A writer whose first connect succeeds and every reconnect afterwards fails.""" + + async def _connect(self) -> None: + self.connects += 1 + if self.connects > 1: + raise OSError("broker unreachable") + + +def make_writer( + field_names: list[str], + *, + script: list[_t.Any] | None = None, + max_retries: int = 3, + writer_cls: type[RecordingWriter] = RecordingWriter, + **kwargs: _t.Any, +) -> RecordingWriter: + """Builds a writer over the given fields.""" + return writer_cls( + name="test-writer", + field_names=field_names, + topic="test-topic", + script=script, + retry_policy=RetryPolicy(max_retries=max_retries), + **kwargs, + ) + + +async def connect_writer(writer: RecordingWriter) -> dict[str, AsyncioConnector]: + """Connects input channels to a writer and returns them by field name.""" + connectors = { + field: AsyncioConnector( + spec=ConnectorSpec(source="none.none", target=f"{writer.name}.{field}") + ) + for field in writer.io.inputs + } + await writer.io.connect(list(connectors.values())) + return connectors + + +async def write_records( + writer: RecordingWriter, + channels: dict[str, AsyncioConnector], + records: list[dict[str, _t.Any]], +) -> None: + """Pushes records into the writer one at a time, stepping after each.""" + senders = {field: await channel.connect_send() for field, channel in channels.items()} + for record in records: + for field, value in record.items(): + await senders[field].send(value) + await writer.step() + + +def delivered(writer: RecordingWriter) -> list[_t.Any]: + """Flattens everything the writer sent.""" + return [record for batch in writer.sends for record in batch] + + +RECORDS = [{"x": 1, "y": "a"}, {"x": 2, "y": "b"}, {"x": 3, "y": "c"}] +SINGLE = [{"x": 1}] # For the single-field writers used by the retry tests. + + +async def test_init_connects_to_broker() -> None: + """Tests that `init` opens the broker connection.""" + writer = make_writer(["x"]) + await writer.init() + assert writer.connects == 1 + await writer.destroy() + + +async def test_destroy_disconnects() -> None: + """Tests that `destroy` closes the broker connection.""" + writer = make_writer(["x"]) + await writer.init() + await writer.destroy() + assert writer.disconnects == 1 + + +async def test_destroy_reports_failed_pending_send( + capsys: pytest.CaptureFixture[str], +) -> None: + """Tests that a send which fails at teardown is reported, not swallowed.""" + writer = make_writer(["x"], script=[ConnectionError("lost")], max_retries=0) + channels = await connect_writer(writer) + await writer.init() + await write_records(writer, channels, SINGLE) + await writer._save_chunk() + + await writer.destroy() # Must not raise: teardown still has to disconnect. + + assert "Pending send failed during destroy" in capsys.readouterr().out + assert writer.disconnects == 1 + + +async def test_destroy_waits_for_in_flight_send() -> None: + """Tests that a send still running at teardown completes instead of being dropped.""" + writer = make_writer(["x"]) + channels = await connect_writer(writer) + await writer.init() + await write_records(writer, channels, [{"x": 1}]) + await writer._save_chunk() # Buffer one batch without waiting for it to land. + await writer.destroy() + assert delivered(writer) == [{"x": 1}] + + +@pytest.mark.parametrize("chunk_size", [1, 2, 3]) +async def test_records_are_sent_in_batches(chunk_size: int) -> None: + """Tests that every record is delivered exactly once, in order.""" + writer = make_writer(["x", "y"], chunk_size=chunk_size) + channels = await connect_writer(writer) + await writer.init() + + await write_records(writer, channels, RECORDS) + await writer.io.close() + await writer.run() + + assert delivered(writer) == RECORDS + assert all(len(batch) <= chunk_size for batch in writer.sends) + + +async def test_run_flushes_remaining_buffer() -> None: + """Tests that data left under the chunk size is still sent when the run ends.""" + writer = make_writer(["x", "y"], chunk_size=10) + channels = await connect_writer(writer) + await writer.init() + + await write_records(writer, channels, RECORDS) + assert writer.sends == [] # Below the chunk size: still buffered. + + await writer.io.close() + await writer.run() + assert delivered(writer) == RECORDS + + +async def test_transient_send_failure_reconnects_and_recovers( + backoff_delays: list[float], +) -> None: + """Tests that a transient failure is retried after reconnecting.""" + writer = make_writer(["x"], script=[MessageBrokerTransientError("blip")], max_retries=3) + channels = await connect_writer(writer) + await writer.init() + + await write_records(writer, channels, SINGLE) + await writer.io.close() + await writer.run() + + assert delivered(writer) == [{"x": 1}] + assert writer.attempts == 2 # Failed once, then succeeded. + assert writer.connects == 2 # Initial connect plus one reconnect. + assert writer.disconnects == 1 + assert backoff_delays == [1.0] # Base delay on the single retry. + + +async def test_permanent_send_failure_is_not_retried() -> None: + """Tests that a permanent broker error surfaces immediately.""" + writer = make_writer( + ["x"], + script=[MessageBrokerPermanentError("topic deleted"), None], + max_retries=3, + ) + channels = await connect_writer(writer) + await writer.init() + await write_records(writer, channels, SINGLE) + + await writer._save_chunk() + send = writer._task + assert send is not None + with pytest.raises(MessageBrokerPermanentError, match="topic deleted"): + await send + + assert writer.attempts == 1 + assert writer.connects == 1 # No reconnect attempted. + writer._task = None # The failed send is already inspected here. + await writer.destroy() + + +async def test_retry_exhaustion_raises_last_send_error( + backoff_delays: list[float], +) -> None: + """Tests that sends are bounded and the broker's own error is raised.""" + failures = [ConnectionError(f"failure {index}") for index in range(5)] + writer = make_writer(["x"], script=failures, max_retries=2) + channels = await connect_writer(writer) + await writer.init() + await write_records(writer, channels, SINGLE) + + await writer._save_chunk() + send = writer._task + assert send is not None + with pytest.raises(ConnectionError, match="failure 2"): + await send + + assert writer.attempts == 3 # Initial attempt plus two retries. + assert writer.connects == 3 # Initial connect plus one reconnect per retry. + assert backoff_delays == [1.0, 2.0] # Backoff doubles on consecutive attempts. + writer._task = None + await writer.destroy() + + +async def test_reconnect_failure_does_not_abort_retries( + backoff_delays: list[float], +) -> None: + """Tests that a failing reconnect skips that attempt but the retries continue. + + Without a connection a send would fail with a synthetic "not connected" error, so a + failed reconnect skips the attempt; the retries keep reconnecting and the error that + is eventually raised is still the broker's own. + """ + writer = make_writer( + ["x"], + script=[ConnectionError("send failed")] * 3, + max_retries=2, + writer_cls=UnreconnectableWriter, + ) + channels = await connect_writer(writer) + await writer.init() + await write_records(writer, channels, SINGLE) + + await writer._save_chunk() + send = writer._task + assert send is not None + with pytest.raises(ConnectionError, match="send failed"): + await send + + assert writer.attempts == 1 # Only the initial send ran; the retries were skipped. + assert writer.connects == 3 # Initial connect plus one reconnect per retry. + writer._task = None + await writer.destroy() + + +def test_iter_records_yields_one_dict_per_row() -> None: + """Tests that a field buffer becomes one record per row, in order.""" + buffer = {"x": deque([1, 2]), "y": deque(["a", "b"])} + assert list(iter_records(buffer)) == [{"x": 1, "y": "a"}, {"x": 2, "y": "b"}] + + +def test_iter_records_stops_at_the_shortest_field() -> None: + """Tests that an incomplete trailing row is not emitted.""" + buffer = {"x": deque([1, 2, 3]), "y": deque(["a"])} + assert list(iter_records(buffer)) == [{"x": 1, "y": "a"}] + + +def test_iter_records_handles_empty_buffers() -> None: + """Tests that empty buffers produce no records.""" + assert list(iter_records({})) == [] + assert list(iter_records({"x": deque()})) == [] + + +def test_iter_records_is_linear_in_row_count() -> None: + """Tests that many buffered rows convert in one pass, not one index per row.""" + rows = 20_000 + buffer = {"x": deque(range(rows)), "y": deque(range(rows))} + records = list(iter_records(buffer)) + assert len(records) == rows + assert records[-1] == {"x": rows - 1, "y": rows - 1} + + +@pytest.mark.parametrize( + ("parse_json", "expected"), + [ + (True, ['{"x": 1, "y": "a"}', '{"x": 2, "y": "b"}']), + (False, ["1", "2"]), + ], +) +def test_encode_records_as_text(parse_json: bool, expected: list[str]) -> None: + """Tests text payloads, JSON-encoded or the raw first field.""" + assert ( + encode_records({"x": deque([1, 2]), "y": deque(["a", "b"])}, parse_json=parse_json) + == expected + ) + + +@pytest.mark.parametrize( + ("parse_json", "expected"), + [ + (True, [b'{"x": 1}', b'{"x": 2}']), + (False, [b"1", b"2"]), + ], +) +def test_encode_records_as_bytes(parse_json: bool, expected: list[bytes]) -> None: + """Tests binary payloads, JSON-encoded or the raw first field.""" + assert encode_records_bytes({"x": deque([1, 2])}, parse_json=parse_json) == expected + + +def test_encode_records_bytes_preserves_binary_payloads() -> None: + """Tests that non-UTF-8 bytes survive a raw (non-JSON) payload untouched.""" + payload = bytes([0xFF, 0xFE, 0x00]) + assert encode_records_bytes({"x": deque([payload])}, parse_json=False) == [payload] + + +def test_default_retry_policy_is_shared_and_immutable() -> None: + """Tests why instantiating `RetryPolicy` in the signature default is safe. + + The default is evaluated once, so every writer that does not override it shares one + instance. That is safe only while the policy stays immutable, so this test locks in + the frozen contract that makes the shared default correct. + """ + first = RecordingWriter(name="first", field_names=["x"], topic="test-topic") + second = RecordingWriter(name="second", field_names=["x"], topic="test-topic") + assert first._retry_policy is second._retry_policy + with pytest.raises(AttributeError, match="immutable"): + setattr(first._retry_policy, "max_retries", 10) + + +async def test_writer_job_id_resolution(job_id_ctx: str) -> None: + """Tests `job_id` precedence: explicit > state > DI context > generated fallback.""" + from_ctx = make_writer(["x"]) + assert from_ctx.job_id == job_id_ctx + + explicit = make_writer(["x"], job_id="Job_explicit123") + assert explicit.job_id == "Job_explicit123" + + state = DictStateBackend(job_id="Job_state1234567") + await state.init() + from_state = make_writer(["x"], state=state) + assert from_state.job_id == "Job_state1234567" + + +def test_writer_job_id_standalone_fallback() -> None: + """Tests that a standalone writer outside a DI context generates a stable `job_id`.""" + writer = make_writer(["x"]) + resolved = writer.job_id + assert EntityIdGen.is_job_id(resolved) + assert writer.job_id == resolved + writer._job_id = None + with writer._job_id_ctx(): + assert EntityIdGen.is_job_id(writer.job_id) diff --git a/uv.lock b/uv.lock index 9b9580a6..7b2ded23 100644 --- a/uv.lock +++ b/uv.lock @@ -52,7 +52,7 @@ wheels = [ [[package]] name = "aiobotocore" -version = "3.4.0" +version = "3.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -63,9 +63,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/50/a48ed11b15f926ce3dbb33e7fb0f25af17dbb99bcb7ae3b30c763723eca7/aiobotocore-3.4.0.tar.gz", hash = "sha256:a918b5cb903f81feba7e26835aed4b5e6bb2d0149d7f42bb2dd7d8089e3d9000", size = 122360, upload-time = "2026-04-07T06:12:24.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/40/659f2ac3915d329e3e8e5bf674d35ce1774f3117c6f60339d5e05e505c36/aiobotocore-3.10.0.tar.gz", hash = "sha256:35480ef11c51e23087273b85d97f80da2f356a46ce5f3fb88e24483cd343ffdb", size = 530781, upload-time = "2026-10-23T10:44:50.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/d8/ce9386e6d76ea79e61dee15e62aa48cff6be69e89246b0ac4a11857cb02c/aiobotocore-3.4.0-py3-none-any.whl", hash = "sha256:26290eb6830ea92d8a6f5f90b56e9f5cedd6d126074d5db63b195e281d982465", size = 88018, upload-time = "2026-04-07T06:12:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/7f/bc/3bb8965204ba51bbf4d021bc89d1357cd2179a17ef71b5fc429e21b511a2/aiobotocore-3.10.0-py3-none-any.whl", hash = "sha256:de0ccbcb82b6b0009f1b6667f48442e9d0432f9b8bfe6172fa51777cbea89d03", size = 102759, upload-time = "2026-10-23T10:44:49.109Z" }, ] [[package]] @@ -91,7 +91,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.5" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -100,78 +100,93 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, - { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, - { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, - { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, - { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, - { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, - { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, - { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, - { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, - { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, - { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, - { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, - { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, - { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, - { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, - { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, - { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, - { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, - { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, - { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, - { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, - { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -647,30 +662,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.84" +version = "1.43.117" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/88/89/2d647bd717da55a8cc68602b197f53a5fa36fb95a2f9e76c4aff11a9cfd1/boto3-1.42.84.tar.gz", hash = "sha256:6a84b3293a5d8b3adf827a54588e7dcffcf0a85410d7dadca615544f97d27579", size = 112816, upload-time = "2026-04-06T19:39:07.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/a7/61f0295f79dfdcebb5aa01f86c614fac7e1294faabbd66259d3609c50539/boto3-1.43.117.tar.gz", hash = "sha256:f3d314908d866b577ee6b74ed432c21e565c4c4dbd640ef3fd5e0da30fa13a98", size = 112677, upload-time = "2026-10-21T19:30:03.849Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/31/cdf4326841613d1d181a77b3038a988800fb3373ca50de1639fba9fa87de/boto3-1.42.84-py3-none-any.whl", hash = "sha256:4d03ad3211832484037337292586f71f48707141288d9ac23049c04204f4ab03", size = 140555, upload-time = "2026-04-06T19:39:06.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3b/22b05a545fb95983733d84681364c6b0a36afb0710d832f77732d1fc4c0b/boto3-1.43.117-py3-none-any.whl", hash = "sha256:a5b6cd7e67653987fcf0e212187c16f7b58af2ae192e368f7212ddab7c05b25d", size = 140043, upload-time = "2026-10-21T19:30:02.114Z" }, ] [[package]] name = "botocore" -version = "1.42.84" +version = "1.43.117" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/b7/1c03423843fb0d1795b686511c00ee63fed1234c2400f469aeedfd42212f/botocore-1.42.84.tar.gz", hash = "sha256:234064604c80d9272a5e9f6b3566d260bcaa053a5e05246db90d7eca1c2cf44b", size = 15148615, upload-time = "2026-04-06T19:38:56.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/a0/d0a5c44ad6a01f195681ddcb4ce203f06c712811daf1d15f8f8785e8a9f8/botocore-1.43.117.tar.gz", hash = "sha256:71993702832cc37c2495048b9945f2929d306fccb3fb767e9964e437e637fa6c", size = 16366888, upload-time = "2026-10-21T19:29:59.274Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/37/0c0c90361c8a1b9e6c75222ca24ae12996a298c0e18822a72ab229c37207/botocore-1.42.84-py3-none-any.whl", hash = "sha256:15f3fe07dfa6545e46a60c4b049fe2bdf63803c595ae4a4eec90e8f8172764f3", size = 14827061, upload-time = "2026-04-06T19:38:53.613Z" }, + { url = "https://files.pythonhosted.org/packages/10/94/050e35c9febf6dd2fce4a76be587ae4a323964865700467d9d7b082f7133/botocore-1.43.117-py3-none-any.whl", hash = "sha256:5920ef9b3002e1361846549078549d9154a6e0004c22853c01ce7b279d05d495", size = 16061765, upload-time = "2026-10-21T19:29:56.001Z" }, ] [[package]] @@ -1422,18 +1437,19 @@ wheels = [ [[package]] name = "google-api-core" -version = "2.30.2" +version = "2.43.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-auth" }, { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, { name = "proto-plus" }, { name = "protobuf" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/2e/83ca41eb400eb228f9279ec14ed66f6475218b59af4c6daec2d5a509fe83/google_api_core-2.30.2.tar.gz", hash = "sha256:9a8113e1a88bdc09a7ff629707f2214d98d61c7f6ceb0ea38c42a095d02dc0f9", size = 176862, upload-time = "2026-04-02T21:23:44.876Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/74/307919972c4dc84598f4128a8d6ae05ac3d9e6ca73fe60a7bb93c670931c/google_api_core-2.43.2.tar.gz", hash = "sha256:5dfc5e9d52982bcc03bd6af380a893365b5fdf907dc527f63d3a372833c33329", size = 286250, upload-time = "2026-10-29T17:20:40.193Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/e1/ebd5100cbb202e561c0c8b59e485ef3bd63fa9beb610f3fdcaea443f0288/google_api_core-2.30.2-py3-none-any.whl", hash = "sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594", size = 173236, upload-time = "2026-04-02T21:23:06.395Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1d/a0120c7d937597293fce74510c80fe0cec8e45b1d702a816b567b6754c25/google_api_core-2.43.2-py3-none-any.whl", hash = "sha256:5a9d21baab97fd3c0547ccfc186a9e64f4227d8d7026f393835316400b29a6d4", size = 224304, upload-time = "2026-10-29T17:20:17.733Z" }, ] [package.optional-dependencies] @@ -1444,15 +1460,15 @@ grpc = [ [[package]] name = "google-auth" -version = "2.49.1" +version = "2.62.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/38/d79ab09dcfc1e1638d887c8f65657619d3ebc454165c558ef79355971ce3/google_auth-2.62.1.tar.gz", hash = "sha256:3c8e1ce2553784ae17fd220e6f88df3281d86ab98019278f436d06dffab17a9e", size = 398519, upload-time = "2026-11-03T03:06:03.008Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, + { url = "https://files.pythonhosted.org/packages/14/dd/2e11f0976ecb3ce48ef3f324837460e9ea50071fa4eb01f1e49df87d3cee/google_auth-2.62.1-py3-none-any.whl", hash = "sha256:015399a5abde39033c69404cc1c079e6d4c2de2993e164c9f4eac6e5376ad72b", size = 267975, upload-time = "2026-11-03T03:06:01.422Z" }, ] [[package]] @@ -1481,6 +1497,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" }, ] +[[package]] +name = "google-cloud-pubsub" +version = "2.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "grpcio-status" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/ce/210febb5edd160e9490f1a7a8190fd870f0997fa8be558b2c902461a27d7/google_cloud_pubsub-2.43.0.tar.gz", hash = "sha256:9a0e01876024ecd5e101a3c41cd67fa50ef0c4e39aa56f53b81524599f90e346", size = 398611, upload-time = "2026-10-15T17:49:51.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/fe/05daee3bd9ca21bf90ce7cf0f62b95bae07c397e107dc1b6762a809a0098/google_cloud_pubsub-2.43.0-py3-none-any.whl", hash = "sha256:db5436dd2d99774171d13c1bf1fb960b6bf3aa23c15694c50090aa569316d19a", size = 323338, upload-time = "2026-10-15T17:49:22.528Z" }, +] + [[package]] name = "google-cloud-storage" version = "3.12.0" @@ -1674,57 +1710,67 @@ wheels = [ [[package]] name = "grpcio" -version = "1.80.0" +version = "1.85.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, - { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, - { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, - { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, - { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, - { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, - { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, - { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, - { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, - { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, - { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, - { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, - { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, - { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, - { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, - { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, - { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, - { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, - { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, - { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, - { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f0/03/f9e805772d4d01da2a1754a5f63c58601eacaf8f1e878729217188de7d16/grpcio-1.85.1.tar.gz", hash = "sha256:5f530287d5de5f7f66089ead81de44f6787162944dab3922efa65dfdff108a1d", size = 14091456, upload-time = "2026-10-30T16:01:16.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/8c/b6d9ea0bf02a6b47ba34c5f936da2921d76f5e40cbb411e9abb246b70d2d/grpcio-1.85.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:1a10d337967ec2be049c6a2345320f6a59f71bf9f54febfd80e5a966cffaa945", size = 6797195, upload-time = "2026-10-30T15:59:35.364Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ee/f60cfacd679fe811944be391c899955c3d9c7e07db922b47e535ddda550c/grpcio-1.85.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:658a9d89c5736bf4aa3ef4ecbc79528e26d8e23c5e1c83417d2870a1a06063e5", size = 12633025, upload-time = "2026-10-30T15:59:37.66Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0d/5dbe225f0ceb208538dc8c16c5f52b0c402b91611f981604b5e280439dfb/grpcio-1.85.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:843aa281a48188621f50880496d35defed51298bcb66d5345d5f282fd3f87354", size = 7170616, upload-time = "2026-10-30T15:59:40.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/35/ce972a4e5bb721ba85f5c9e20a0029253ae0b2deb5276dafa3383e025133/grpcio-1.85.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e768556a2346d1d2921f511f3145e2462e4fe68095c31e4db407558bcd3f1d4f", size = 8180786, upload-time = "2026-10-30T15:59:42.7Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/74a8649d2787dae0a661855cdf09a8e2238fa741689bb368ca1b4f6d5b6f/grpcio-1.85.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a1a72d19c0ab84b4b73b6c47eeaade76d84b1973a51593ff1160a73df33d03d9", size = 7362744, upload-time = "2026-10-30T15:59:45.076Z" }, + { url = "https://files.pythonhosted.org/packages/03/72/87ef13aba6bd2c5c6d4c5117238b17b1e3c1c1efa26c66cb377053124eb7/grpcio-1.85.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0e07f477e1d1a05bdbe71d63add964c5457e7ab2e3f5fd32859639ed8ecb976e", size = 7929643, upload-time = "2026-10-30T15:59:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/09/f1/2e75b2906c9643e0be81a74bf23ae10c29661e0199c8f3a6e2c23707e882/grpcio-1.85.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2846c70ed58c8e149e8e5d867af40805a13b151d84157d8272952d711a77c5c", size = 9181161, upload-time = "2026-10-30T15:59:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d9/6b9127e8fa3ef14a3ec199436bec6c1a54a0bb429ca261b1a2fb518938d1/grpcio-1.85.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9998f03ac0dfed226eead6e93cee1b547d7a32e47e1915dee94eff0e113b167e", size = 8318829, upload-time = "2026-10-30T15:59:52.09Z" }, + { url = "https://files.pythonhosted.org/packages/21/46/ff3374d33efee490a0e50e3c3fc59280b7f7fc2ed3ab1ee8c4e9873dc83d/grpcio-1.85.1-cp312-cp312-win32.whl", hash = "sha256:2670c03d154f6bec66a0fef97c607116761b032183187dfdfe229b5975820a4f", size = 4768056, upload-time = "2026-10-30T15:59:54.132Z" }, + { url = "https://files.pythonhosted.org/packages/dd/48/4030a63540089d00a36cea15282623bcf084a5ea4c0ffc4685a42ef0f342/grpcio-1.85.1-cp312-cp312-win_amd64.whl", hash = "sha256:eaad7e6aa9ac3aded4f6bc86a18bf2cff3de356f972bf32a5558df9788085b85", size = 5607218, upload-time = "2026-10-30T15:59:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/c1/05/2d40be7dbfd8e66e8e5675ec695fafacc69823dcd2ef5d387ad7431c7af7/grpcio-1.85.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:e117b074d707d418ff411e0ad6fc86e97e83047b742488b41d7a5b7a62953cd3", size = 6801759, upload-time = "2026-10-30T15:59:58.834Z" }, + { url = "https://files.pythonhosted.org/packages/b0/98/530af74d704dc8e79c79b26888af44ccbdd1827bfabc9cb405b3d4cc184e/grpcio-1.85.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:6bb4bbb5cb46010687db552fd88852c405595814fb7d435b072124bd6b93f827", size = 12629341, upload-time = "2026-10-30T16:00:01.159Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/bac12e6c7ab3b1c6b50816fdcbf7f97aec1bd0992cc0d193e332aff716a5/grpcio-1.85.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bde4264ca71aaf3bc56c587380c426b1c959d1db0ab0a3f632bc66515f9726f", size = 7174387, upload-time = "2026-10-30T16:00:04.847Z" }, + { url = "https://files.pythonhosted.org/packages/90/0a/7770a652508a3a6c7d16fbb81db0f0b2be8a73a469af88a6cdfdc3d29cf2/grpcio-1.85.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:7b931335c163510fb054c30007fe3556ef3bf354a1c37b5da403f2e52c6bbd95", size = 8185778, upload-time = "2026-10-30T16:00:07.009Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cb/52417fb272b7603d13ec8688e843dfd8df64584da1951ad954b9120d7c80/grpcio-1.85.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:de520b1555ae445cdc4f8f1cc7ccba12a19992bfafa7c292ebe7fe1fe5da6c53", size = 7364062, upload-time = "2026-10-30T16:00:09.777Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/9329799fa9defe9327084b9ca7b9a9745e83e7174bb90f2bfb2f37063967/grpcio-1.85.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2b20ba567ca818b8393fd4668878b5688cab32a46e8e7a4d1a58641fc0072df9", size = 7932775, upload-time = "2026-10-30T16:00:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/48/e4/826e0ae8f89a3d2ab3a15b91ee4ef486bdd7a09fd1ff72e12d01505341dc/grpcio-1.85.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f0ebfbd8ba42497e0ff7229c8c5e166e07c2b348462c1481dbd90faa978ad8af", size = 9173461, upload-time = "2026-10-30T16:00:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/88/2d/d4baf37bf1a9ba8222dcb133c6aeb09119b183bd7b471061762a1198461c/grpcio-1.85.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:000999ea9bba80217b1625782d638e7a688790f6154e845e18826f7da859bfc7", size = 8311104, upload-time = "2026-10-30T16:00:18.533Z" }, + { url = "https://files.pythonhosted.org/packages/e4/02/b07af232a583727fa84665d11165bbd7be6ddd31d84c2b8cb62a7b69062f/grpcio-1.85.1-cp313-cp313-win32.whl", hash = "sha256:b3722f4e01820f4d2422679a567fe33f31b6f14eba4352657a1078f4b4ed323e", size = 4769351, upload-time = "2026-10-30T16:00:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f0/d2448e860d884e8c89dd9135cd9373df064c10227d90973d822c2125428d/grpcio-1.85.1-cp313-cp313-win_amd64.whl", hash = "sha256:c17d61af28b322c48ab3881c685e5f6e7e367c56844cf589ee7707be9477b969", size = 5606831, upload-time = "2026-10-30T16:00:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/39/cc/da49c22b9c571b5315acc507e2ff6568e5ff9d4c99895285910283bf87e3/grpcio-1.85.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:2a378eb60ea42996be5a8c7167302fb30d968fcfcf9edd7b3fa6c985906c4500", size = 6803626, upload-time = "2026-10-30T16:00:26.587Z" }, + { url = "https://files.pythonhosted.org/packages/12/92/35bcee92bcfc2b0f66290a5dd6dd23ced7b9423fe269cf4495136e73d55b/grpcio-1.85.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:90a7e0f26de39d15f5c741b8848bb2065c9ab7351c57d4fc973ff5cbda0cb9c7", size = 12640307, upload-time = "2026-10-30T16:00:28.969Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/469b69dadb2c413706f16e0c3e7a2749cc70abaab10d718873603bec29b2/grpcio-1.85.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:421925365f9a665830eded01b8053033abbc2fbabc9bb4e9c59c3b5b12acbab9", size = 7187308, upload-time = "2026-10-30T16:00:31.652Z" }, + { url = "https://files.pythonhosted.org/packages/c4/df/2fcb1567d37e91ce26bddfca30f39fe6c39cbc5a2f4a210cfb1246ced1b2/grpcio-1.85.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8a921a98ed6142a29651b4a6f4c7b8e051d115f1e78c31655a9642dcf6356dd9", size = 8188002, upload-time = "2026-10-30T16:00:33.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b2/c67c1d8c7b70a094d404b28233458887bc1ca1319daa95d8952621216717/grpcio-1.85.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e0e6d5829b50a2ffcfcd2dbf08d02ac0d6919051a2e97c2ee27c99bc9916a245", size = 7368045, upload-time = "2026-10-30T16:00:36.675Z" }, + { url = "https://files.pythonhosted.org/packages/80/48/5cf6c704be4b059d508e8f6c981154223533595ba7a3ee531211ef8be62e/grpcio-1.85.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:58b5ebe509da292bec5a1913c1e4c14d842a33f6cb330a87d3713c5089dd0f55", size = 7941330, upload-time = "2026-10-30T16:00:39.173Z" }, + { url = "https://files.pythonhosted.org/packages/67/96/5ef14edb784ea2053610753993601e98a76a18b541a8a74103dc390540ab/grpcio-1.85.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:016982ebd21d2b48e955aa808e9db8d628708a35c6eba852cd0e70765826ab06", size = 9176909, upload-time = "2026-10-30T16:00:41.335Z" }, + { url = "https://files.pythonhosted.org/packages/50/01/4264ae95364bdeede4c8a5f0d83df83a21b477935f9ab8c829ae5606e1d4/grpcio-1.85.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fa3d7b8b95e8a609967991c3b0a302b2f9d510fe417efadfa7c7d2d044a99ba", size = 8316226, upload-time = "2026-10-30T16:00:44.199Z" }, + { url = "https://files.pythonhosted.org/packages/75/b8/79dace38eb4bb904751b804cfdc71609a3840f34084af7475169059dce20/grpcio-1.85.1-cp314-cp314-win32.whl", hash = "sha256:39b1965266b579f3758db20d74f9075b130d1b1edcf0464cda316e403a15da9b", size = 4853260, upload-time = "2026-10-30T16:00:47.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c9/68a6bbd2450f08942c3056acd8995ac28af2b4eeb0574c0a8e59fa59b140/grpcio-1.85.1-cp314-cp314-win_amd64.whl", hash = "sha256:5f015600190124813032da0560ec950c13c257f77c1307abd77c393c0994f76f", size = 5758958, upload-time = "2026-10-30T16:00:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/1a2481b354ab4528eb8b3462937904c9206e2a54748f3f74a2576205ef3e/grpcio-1.85.1-cp315-cp315-linux_armv7l.whl", hash = "sha256:07bfef5df75875d8481d3403f864785e30096594162e8041c53a84fb11ee4f8e", size = 6803392, upload-time = "2026-10-30T16:00:51.782Z" }, + { url = "https://files.pythonhosted.org/packages/40/45/1868c12c18058d778fcecee0e04c867891a82d983d2dd0de2639158aa960/grpcio-1.85.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:e909e0f9145a555d3968dd2a448616b0d3cb58748b9e2053de3e7974b3e429bc", size = 12657793, upload-time = "2026-10-30T16:00:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/ed/af/c283032a42df5217913b98bd0113738b68dd94b91cea16b2209565fefe67/grpcio-1.85.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1f3064e209167e5fcb75a72cbc18918c06027b92b29fa06eaff0126f9a2aab5a", size = 7209146, upload-time = "2026-10-30T16:00:58.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/64/8a72fd6c20c206cff07eaa49b2ca63bb15d7ed9b6bf7c653ea10ec578e4b/grpcio-1.85.1-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:07675c635a2da0c772e58ac736a7fb10c7c7c1613157766441e6866ad97d25b4", size = 7511067, upload-time = "2026-10-30T16:01:00.806Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b8/32afa404288c7eea4c6034d031f1a07eff772a8cdbfc7751314382e7b724/grpcio-1.85.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:759f7df78954e3ac0ed4c87612eff9125021b9271be37be8c88167aa533e29ee", size = 7410227, upload-time = "2026-10-30T16:01:02.966Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/1c51e2c2d0d9e4d37e8e8a7be412c5dfb3fd5c80ab100dd531fd81b44d43/grpcio-1.85.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:7cd08bd5ad063ad9639dbebf95d3a6b8c8aa2c9d49049bc0e8f47e73ecf1c1a2", size = 7964886, upload-time = "2026-10-30T16:01:05.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/f3/e26f570129c162d8f15a02af6edb49e592ac1c88a63490cd3ec9142d8c67/grpcio-1.85.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:2198269c8b69cb1e9d5d20628029245e40b7227d5f6fc6f298a420701d8fb652", size = 8504060, upload-time = "2026-10-30T16:01:07.82Z" }, + { url = "https://files.pythonhosted.org/packages/16/e1/e334cf7442c01892ed813563b21b2a8ab5b042e34f33ad76cf64526d1de8/grpcio-1.85.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7bc5de50374416223bc35332aa8185567ec065f86190ff0ca6e13176ed39a3b4", size = 8346563, upload-time = "2026-10-30T16:01:10.394Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/cf72913ce0baae1fed95445f1ba8ac309184ac16b4f20fb5f88219eff72c/grpcio-1.85.1-cp315-cp315-win32.whl", hash = "sha256:5740aedf1e6acde538d7b80a2507eb6ee0b8983c577a36890d27a0e2b62cc1eb", size = 4853404, upload-time = "2026-10-30T16:01:12.239Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b8/b7cff7b9e1ff7b6a8d36a19ce8b9c575963e71fcbb3878b276cff6748b05/grpcio-1.85.1-cp315-cp315-win_amd64.whl", hash = "sha256:b32f39c56d22d2e8722eececd4376074d94e01595a29f4f944ae0b6cea1caa70", size = 5758584, upload-time = "2026-10-30T16:01:14.325Z" }, ] [[package]] name = "grpcio-status" -version = "1.80.0" +version = "1.85.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/16/0a161d0016f6e318ebf217780a91e007a67d9d244a06f79caeef689f5f02/grpcio_status-1.85.1.tar.gz", hash = "sha256:3f6ba6090d794b7148b23f03355addd56bee2b78174a12784c4d9b4e28c8928e", size = 13982, upload-time = "2026-10-30T16:05:25.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e9/5e34e2543b586ffddf90721ab242e7b662c063484ee3978bf61ca90d7579/grpcio_status-1.85.1-py3-none-any.whl", hash = "sha256:ef84cb262c7a05fd7c27e61bb70c9f63bce4484ce8b3366585a381fed738fcb0", size = 14640, upload-time = "2026-10-30T16:05:11.252Z" }, ] [[package]] @@ -1812,8 +1858,8 @@ name = "httpcore2" version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "sys_platform != 'emscripten'" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ @@ -1891,18 +1937,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -3523,29 +3557,28 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.41.0" +version = "1.45.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/cf3a6b6448849656e1e5f4e2101f3988dc6789724050096aba1bc5ccf592/opentelemetry_api-1.45.2.tar.gz", hash = "sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4", size = 73277, upload-time = "2026-10-29T17:29:37.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, + { url = "https://files.pythonhosted.org/packages/a9/2d/610db864d7513158cebb8f36cc88e67e73bd6c7aa1905ef18d89e1281c81/opentelemetry_api-1.45.2-py3-none-any.whl", hash = "sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34", size = 60335, upload-time = "2026-10-29T17:29:06.955Z" }, ] [[package]] name = "opentelemetry-exporter-prometheus" -version = "0.62b0" +version = "0.66b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, { name = "prometheus-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/ec/fa8a722199dc2e75dc582779d62207b00b0bdb014b5635594afa0cf3ee43/opentelemetry_exporter_prometheus-0.62b0.tar.gz", hash = "sha256:4d1106566a9b3e8dff028e69e9f2dc90723e6b431c900ff8c72982fcf11dbae5", size = 15441, upload-time = "2026-04-09T14:38:38.934Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c5/d49a08ebd180fb1471ae44ccaa265abf56e95cb5164ed5208e1663584b06/opentelemetry_exporter_prometheus-0.66b2.tar.gz", hash = "sha256:ada6b2507a31e10405f686e6a8498cb463a6931b5791337b04cfe14288db497f", size = 17337, upload-time = "2026-10-29T17:29:46.903Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/1e/43645fadd561471af2aec95906a3dd54af1a8e7782322310e802a810ad3a/opentelemetry_exporter_prometheus-0.62b0-py3-none-any.whl", hash = "sha256:cd7e8acae3be5f425ffa2e0864eea474fa7a40706f786de7a2d23846573d8f75", size = 13278, upload-time = "2026-04-09T14:38:19.367Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ec/91617f3e9bbe50487fd899a9efee4d111eabc3c61b66c2ef3ecf0aaba68e/opentelemetry_exporter_prometheus-0.66b2-py3-none-any.whl", hash = "sha256:b73998788aa30bec3bfe20fae312650878adb59f6563ed8bf37c188cb450a189", size = 13371, upload-time = "2026-10-29T17:29:21.28Z" }, ] [[package]] @@ -3562,29 +3595,29 @@ wheels = [ [[package]] name = "opentelemetry-sdk" -version = "1.41.0" +version = "1.45.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/0e/a586df1186f9f56b5a0879d52653effc40357b8e88fc50fe300038c3c08b/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", size = 230181, upload-time = "2026-04-09T14:38:47.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/1a/74a21d4d6969fb8b432ac397e66112a8167a6deb267ea4559dbaf2fe91ef/opentelemetry_sdk-1.45.2.tar.gz", hash = "sha256:7eec4de13dd21ff3a1670dcb0e02079e692736f94d733dfdce5af461d884248d", size = 221705, upload-time = "2026-10-29T17:29:54.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/13/a7825118208cb32e6a4edcd0a99f925cbef81e77b3b0aedfd9125583c543/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd", size = 180214, upload-time = "2026-04-09T14:38:30.657Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a5/9a3b793ef48245f2336a16beb2324c2397dbb3f987cd68942f77cca06156/opentelemetry_sdk-1.45.2-py3-none-any.whl", hash = "sha256:ff974ea70ee4f1d51e2ec152db37c2b626c7bf82bb3f7d86114fd8704e7199c6", size = 141756, upload-time = "2026-10-29T17:29:33.722Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.62b0" +version = "0.66b2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/b0/c14f723e86c049b7bf8ff431160d982519b97a7be2857ed2247377397a24/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097", size = 145753, upload-time = "2026-04-09T14:38:48.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/3f/50f95972b50e8e353f5b9342034c9cd17730f1956f009ce6a3fbbd89f9ce/opentelemetry_semantic_conventions-0.66b2.tar.gz", hash = "sha256:7da5f1a5dc4383eb66711d4d3d3e71824d9403ac1559fea021cc103c1a4c4976", size = 150246, upload-time = "2026-10-29T17:29:55.682Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/6bf33ed31521c565931c037e9e81bac47c1acb000e850c6e24220e2a0c00/opentelemetry_semantic_conventions-0.66b2-py3-none-any.whl", hash = "sha256:4cbe61e5f9275d57ab31708446e7938ef4291ebbe6a7fca6f7a629c5acae87d8", size = 206280, upload-time = "2026-10-29T17:29:35.062Z" }, ] [[package]] @@ -3849,12 +3882,18 @@ dependencies = [ aws = [ { name = "s3fs" }, ] +aws-messaging = [ + { name = "aiobotocore" }, +] azure = [ { name = "adlfs" }, ] gcp = [ { name = "gcsfs" }, ] +gcp-pubsub = [ + { name = "google-cloud-pubsub" }, +] llm = [ { name = "llama-index-core" }, { name = "llama-index-llms-openai" }, @@ -3876,9 +3915,11 @@ websockets = [ [package.dev-dependencies] all = [ + { name = "aiobotocore" }, { name = "aiofile" }, { name = "aiosqlite" }, { name = "coverage" }, + { name = "google-cloud-pubsub" }, { name = "hatch" }, { name = "hatch-vcs" }, { name = "ipython" }, @@ -3935,9 +3976,11 @@ docs = [ { name = "mkdocstrings", extra = ["python"] }, ] test = [ + { name = "aiobotocore" }, { name = "aiofile" }, { name = "aiosqlite" }, { name = "coverage" }, + { name = "google-cloud-pubsub" }, { name = "llama-index-core" }, { name = "llama-index-llms-openai" }, { name = "moto", extra = ["server"] }, @@ -3960,11 +4003,13 @@ test = [ requires-dist = [ { name = "adlfs", marker = "extra == 'azure'", specifier = ">=2024.7.0" }, { name = "aio-pika", specifier = ">=9.5.5,<11" }, + { name = "aiobotocore", marker = "extra == 'aws-messaging'", specifier = ">=3.4,<4" }, { name = "aiosqlite", specifier = ">=0.20,<1" }, { name = "annotated-types", specifier = ">=0.7,<1" }, { name = "asyncpg", specifier = ">=0.29.0,<1" }, { name = "fsspec", specifier = ">=2024.9.0" }, { name = "gcsfs", marker = "extra == 'gcp'", specifier = ">=2024.9.0" }, + { name = "google-cloud-pubsub", marker = "extra == 'gcp-pubsub'", specifier = ">=2.25,<3" }, { name = "httpx2", specifier = ">=2,<3" }, { name = "jsonschema", marker = "extra == 'ray'", specifier = "<4.25.0" }, { name = "llama-index-core", marker = "extra == 'llm'", specifier = ">=0.12.30,<1" }, @@ -3990,13 +4035,15 @@ requires-dist = [ { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.21.0,<1" }, { name = "websockets", marker = "extra == 'websockets'", specifier = ">=14.2,<18" }, ] -provides-extras = ["aws", "azure", "gcp", "llm", "omq", "ray", "redis", "websockets"] +provides-extras = ["aws", "aws-messaging", "azure", "gcp", "gcp-pubsub", "llm", "omq", "ray", "redis", "websockets"] [package.metadata.requires-dev] all = [ + { name = "aiobotocore", specifier = ">=3.4,<4" }, { name = "aiofile", specifier = ">=3.9,<4" }, { name = "aiosqlite", specifier = ">=0.20,<1" }, { name = "coverage", specifier = ">=7.7.1,<8" }, + { name = "google-cloud-pubsub", specifier = ">=2.25,<3" }, { name = "hatch", specifier = ">=1.14.1" }, { name = "hatch-vcs", specifier = ">=0.5.0" }, { name = "ipython", specifier = ">=8.26,<10" }, @@ -4053,9 +4100,11 @@ docs = [ { name = "mkdocstrings", extras = ["python"], specifier = ">=0.25,<2" }, ] test = [ + { name = "aiobotocore", specifier = ">=3.4,<4" }, { name = "aiofile", specifier = ">=3.9,<4" }, { name = "aiosqlite", specifier = ">=0.20,<1" }, { name = "coverage", specifier = ">=7.7.1,<8" }, + { name = "google-cloud-pubsub", specifier = ">=2.25,<3" }, { name = "llama-index-core", specifier = ">=0.12.30,<1" }, { name = "llama-index-llms-openai", specifier = ">=0.3.33,<1" }, { name = "moto", extras = ["server"], specifier = ">=5.0,<6" }, @@ -5253,14 +5302,14 @@ wheels = [ [[package]] name = "s3transfer" -version = "0.16.0" +version = "0.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, ] [[package]] @@ -5268,8 +5317,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "jeepney", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -6180,66 +6229,51 @@ wheels = [ [[package]] name = "wrapt" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, - { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, - { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, - { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, - { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, - { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, - { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, - { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, - { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, - { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, - { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, - { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, - { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, - { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, - { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, - { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, - { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, - { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, - { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, - { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, - { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" }, - { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" }, - { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" }, - { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" }, - { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" }, - { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" }, - { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" }, - { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" }, - { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" }, - { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" }, - { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" }, - { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" }, - { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" }, - { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" }, - { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" }, - { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" }, - { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" }, - { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" }, - { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" }, - { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]] @@ -6368,12 +6402,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, -]