diff --git a/.gitignore b/.gitignore index 9107d228..8b7a80bc 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,4 @@ cython_debug/ # Generated version files */_version.py +docs/message-data-reader-writer-proposal.md diff --git a/README.md b/README.md index f38593eb..1efbf135 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]`, `plugboard[aws-messaging]` or `plugboard[kafka]` for [message data components](https://docs.plugboard.dev/usage/message-data/). ## ⚡ Quickstart with AI diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 2f9e70db..5430c2eb 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -24,6 +24,26 @@ 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 +evironment 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 | | +| `KAFKA_BOOTSTRAP_SERVERS` | Kafka broker address(es) | | + +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..039db62a --- /dev/null +++ b/docs/usage/message-data.md @@ -0,0 +1,150 @@ +# 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 | +| [`KafkaDataReader`][plugboard.library.KafkaDataReader] | Apache Kafka | a topic + consumer group | | +| [`KafkaDataWriter`][plugboard.library.KafkaDataWriter] | Apache Kafka | | a topic | + +All six 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 +pip install "plugboard[kafka]" # aiokafka +``` + +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.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.y > 50 else "low" + + +async def main() -> None: + reader = GCPPubSubDataReader( + name="reader", + subscription_id="measurements-pull", + project_id="plugboard-dev", # Optional; defaults to GCP_PUBSUB_PROJECT_ID + 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=[ + ConnectorSpec(source="reader.x", target="bucket.x"), + ConnectorSpec(source="reader.y", target="bucket.y"), + ConnectorSpec(source="bucket.bucket", target="writer.bucket"), + ], + ) + 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 from a broker means "nothing yet", +not "no more data" — unlike a file, which ends. A reader started before its producer +keeps running and picks up messages as they arrive. To end a reader's stream, the broker +must report the source as gone (for example a deleted PubSub subscription or SQS queue), +which raises `IOStreamClosedError` like other data readers do. Stop the process to end a +run that would otherwise wait forever. + +**Messages are acknowledged after they are processed.** A batch fetched from the broker +is acknowledged only once every record in it has been published downstream, so a crash +mid-batch leaves the unread messages to be redelivered (at-least-once). For SQS, +"acknowledge" means deleting the messages; for Kafka it means committing the offsets of +the processed records, per partition. + +**Batching is bounded by the broker.** `chunk_size` sets how many messages are fetched +per poll (and per send for writers). SQS delivers at most 10 messages per call, so a +larger `chunk_size` is capped there and a warning is logged. + +**Failures are retried with backoff.** Transient broker errors trigger a reconnect and a +retry, doubling the delay up to a cap. Permanent errors — such as `AccessDenied` or a +deleted topic — fail immediately instead of burning the retries. Both the classification +and the policy are handled by the base classes: + +```python +from plugboard.library import KafkaDataReader +from plugboard.utils.retry import RetryPolicy + +reader = KafkaDataReader( + name="reader", + topic="measurements", + group_id="plugboard", + field_names=["x", "y"], + bootstrap_servers="localhost:9092", # Optional; defaults to KAFKA_BOOTSTRAP_SERVERS + retry_policy=RetryPolicy(max_retries=5, base_delay=0.5, max_delay=30.0), + idle_poll_delay=0.5, # Pause between empty polls, if the broker returns immediately +) +``` + +**Message 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 — useful for single-value or binary messages. + +## Configuration + +Connection details can be passed explicitly or read from the environment, which lets the +same model run against a different account or region without code changes. Explicit +arguments always win. + +| Option Name | Description | Used by | +|---------------------------|------------------------------------------|---------| +| `GCP_PUBSUB_PROJECT_ID` | Default GCP project for PubSub topics | `GCPPubSubDataReader`, `GCPPubSubDataWriter` | +| `AWS_REGION` | Default AWS region for SQS/SNS clients | `AWSSQSDataReader`, `AWSSNSDataWriter` | +| `KAFKA_BOOTSTRAP_SERVERS` | Default Kafka broker address(es) | `KafkaDataReader`, `KafkaDataWriter` | + +Credentials themselves are never configured through Plugboard: the clients use their +normal provider mechanisms (Application Default Credentials for PubSub, the standard AWS +credential chain, SASL settings for Kafka). 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..c4bd2ac2 100644 --- a/plugboard/library/__init__.py +++ b/plugboard/library/__init__.py @@ -1,20 +1,33 @@ """Provides implementations of Plugboard objects for use in user models.""" +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 .kafka_io import KafkaDataReader, KafkaDataWriter 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", + "KafkaDataReader", + "KafkaDataWriter", + "LLMChat", + "LLMImageProcessor", + "MessageDataReader", + "MessageDataWriter", "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..092104a4 --- /dev/null +++ b/plugboard/library/aws_messaging_io.py @@ -0,0 +1,336 @@ +"""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 _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, "region", "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, + } + try: + response = await self._client.receive_message(**request) + except Exception as error: # noqa: BLE001 + raise _wrap_error(error, exhausted=True) from error + return response.get("Messages", []) + + 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: + body = msg.get("Body", "") + if self._parse_json: + record = json.loads(body) + else: + record = {"data": body} + 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, DeleteMessageRequests=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, "region", "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") + for msg_data in messages: + try: + await self._client.publish(TopicArn=self._topic_arn, Message=msg_data) + 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, 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..1dcdb795 --- /dev/null +++ b/plugboard/library/gcp_pubsub_io.py @@ -0,0 +1,297 @@ +"""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.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. + _PERMANENT_ERRORS: tuple[type[Exception], ...] = ( + gcp_exceptions.PermissionDenied, + gcp_exceptions.Unauthorized, + gcp_exceptions.InvalidArgument, + gcp_exceptions.MethodNotImplemented, + gcp_exceptions.FailedPrecondition, + ) + + # 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) -> Exception: + """Maps a PubSub SDK error onto the Plugboard message broker exception hierarchy. + + Args: + error: The error raised by the PubSub client. + + Returns: + A `NoMoreDataException` for a deleted topic/subscription, 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 isinstance(error, _PERMANENT_ERRORS): + return MessageBrokerPermanentError(str(error)) + if isinstance(error, _TRANSIENT_ERRORS): + return MessageBrokerTransientError(str(error)) + return error + + +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, + "project_id", + "GCP_PUBSUB_PROJECT_ID", + ) + kwargs.setdefault("topic", f"{project_id}/{subscription_id}") + super().__init__(**kwargs) + self._project_id = project_id + self._subscription_id = subscription_id + 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 Exception as error: # noqa: BLE001 + raise _wrap_error(error) from error + return list(response.received_messages) + + 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: + payload = msg_wrapper.message.data + if self._parse_json: + record = json.loads(payload.decode("utf-8")) + else: + record = {"data": payload} + 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, + "project_id", + "GCP_PUBSUB_PROJECT_ID", + ) + kwargs.setdefault("topic", f"{project_id}/{topic_id}") + super().__init__(**kwargs) + self._project_id = project_id + self._topic_id = topic_id + 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 + + def _publish() -> None: + futures = [publisher.publish(self._topic_path, data=msg_data) 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, self._parse_json) diff --git a/plugboard/library/kafka_io.py b/plugboard/library/kafka_io.py new file mode 100644 index 00000000..133a34b3 --- /dev/null +++ b/plugboard/library/kafka_io.py @@ -0,0 +1,297 @@ +"""Provides `KafkaDataReader` and `KafkaDataWriter` for Apache Kafka messaging.""" + +from __future__ import annotations + +import asyncio +from collections import deque +import json +import typing as _t + +from plugboard.exceptions import ( + MessageBrokerConnectionError, + MessageBrokerPermanentError, + MessageBrokerTransientError, +) +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 aiokafka import AIOKafkaConsumer, AIOKafkaProducer + from aiokafka.errors import ( + CoordinatorNotAvailableError, + KafkaConnectionError, + NodeNotReadyError, + NotLeaderForPartitionError, + RecordTooLargeError, + RequestTimedOutError, + TopicAuthorizationFailedError, + UnknownTopicOrPartitionError, + ) + from aiokafka.structs import OffsetAndMetadata, TopicPartition + + # Kafka failures that retrying cannot resolve, such as missing authorization or a + # record the broker will always reject. + _PERMANENT_ERRORS: tuple[type[Exception], ...] = ( + TopicAuthorizationFailedError, + RecordTooLargeError, + ) + + # Kafka failures raised while the cluster is rebalancing or a broker is unreachable, + # which are expected to clear. + _TRANSIENT_ERRORS: tuple[type[Exception], ...] = ( + KafkaConnectionError, + NodeNotReadyError, + CoordinatorNotAvailableError, + NotLeaderForPartitionError, + RequestTimedOutError, + UnknownTopicOrPartitionError, + ) +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 = () + + +def _wrap_error(error: Exception) -> Exception: + """Maps an aiokafka error onto the Plugboard message broker exception hierarchy. + + Args: + error: The error raised by the Kafka client. + + Returns: + 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, _PERMANENT_ERRORS): + return MessageBrokerPermanentError(str(error)) + if isinstance(error, _TRANSIENT_ERRORS): + return MessageBrokerTransientError(str(error)) + return error + + +class KafkaDataReader(MessageDataReader): + """Reads data from an Apache Kafka topic. + + Messages are consumed from the topic using a consumer group. Acknowledging a batch + commits the offsets following the records that were consumed, so a failure before + that point leaves the remaining records for redelivery. + """ + + @depends_on_optional("aiokafka", extra="kafka") + def __init__( + self, + group_id: str, + bootstrap_servers: _t.Optional[str | list[str]] = None, + parse_json: bool = True, + poll_timeout_ms: int = 30_000, + **kwargs: _t.Unpack[MessageDataReaderArgsDict], + ) -> None: + """Instantiates the `KafkaDataReader`. + + Args: + group_id: Consumer group ID. + bootstrap_servers: Optional; Kafka broker address(es), for example + `"localhost:9092"`. Falls back to `KAFKA_BOOTSTRAP_SERVERS`. + parse_json: Whether to parse message values as JSON. + poll_timeout_ms: Milliseconds to wait for a batch of records. + **kwargs: Additional keyword arguments for + [`MessageDataReader`][plugboard.library.MessageDataReader]. + """ + bootstrap_servers = resolve_argument( + bootstrap_servers, + DI.settings.resolve_sync().kafka.bootstrap_servers, + "bootstrap_servers", + "KAFKA_BOOTSTRAP_SERVERS", + ) + super().__init__(**kwargs) + self._bootstrap_servers = ( + [bootstrap_servers] if isinstance(bootstrap_servers, str) else bootstrap_servers + ) + self._group_id = group_id + self._parse_json = parse_json + self._poll_timeout_ms = poll_timeout_ms + self._consumer: _t.Optional[AIOKafkaConsumer] = None + + async def _connect(self) -> None: + """Creates and starts a Kafka consumer.""" + consumer = AIOKafkaConsumer( + self._topic, + bootstrap_servers=self._bootstrap_servers, + group_id=self._group_id, + auto_offset_reset="earliest", + enable_auto_commit=False, + max_poll_records=self._chunk_size or 10, + ) + await consumer.start() + self._consumer = consumer + + async def _disconnect(self) -> None: + """Stops and closes the Kafka consumer.""" + if self._consumer is not None: + consumer = self._consumer + self._consumer = None + await consumer.stop() + + async def _receive(self) -> list[_t.Any]: + """Receives a batch of messages from the Kafka topic. + + Returns: + A list of Kafka `ConsumerRecord` objects, empty if the poll timed out. + + Raises: + MessageBrokerError: If the poll failed for a retryable or permanent reason. + """ + if self._consumer is None: + raise MessageBrokerConnectionError("Kafka consumer is not connected") + max_messages = self._chunk_size or 10 + try: + data = await self._consumer.getmany( + timeout_ms=self._poll_timeout_ms, max_records=max_messages + ) + except Exception as error: # noqa: BLE001 + raise _wrap_error(error) from error + return [record for records in data.values() for record in records] + + async def _convert(self, data: list[_t.Any]) -> dict[str, deque]: + """Converts Kafka messages to a field buffer. + + Args: + data: A list of `ConsumerRecord` 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 record in data: + value = record.value + if isinstance(value, bytes): + value = value.decode("utf-8") + if self._parse_json: + record_data = json.loads(value) + else: + record_data = {"data": value} + for field in self.io.outputs: + converted[field].append(record_data.get(field)) + return converted + + async def _ack(self, messages: list[_t.Any]) -> None: + """Commits the offsets that follow the processed Kafka messages. + + Only the offsets of the records that were actually consumed are committed, so + records still waiting in the buffer stay eligible for redelivery. + + Args: + messages: The `ConsumerRecord` objects that have been processed. + + Raises: + MessageBrokerConnectionError: If the consumer is not connected. + MessageBrokerError: If the commit failed. + """ + if self._consumer is None: + raise MessageBrokerConnectionError("Kafka consumer is not connected") + offsets: dict[TopicPartition, OffsetAndMetadata] = {} + for record in messages: + partition = TopicPartition(record.topic, record.partition) + next_offset = OffsetAndMetadata(record.offset + 1, "") + current = offsets.get(partition) + if current is None or current.offset < next_offset.offset: + offsets[partition] = next_offset + if not offsets: + return + try: + await self._consumer.commit(offsets=offsets) + except Exception as error: # noqa: BLE001 + raise _wrap_error(error) from error + + +class KafkaDataWriter(MessageDataWriter): + """Writes data to an Apache Kafka topic. + + Field data is converted to JSON-encoded messages and produced to the specified + topic. Records are submitted together and flushed once, so the producer's internal + batching is used instead of one round trip per record. + """ + + @depends_on_optional("aiokafka", extra="kafka") + def __init__( + self, + bootstrap_servers: _t.Optional[str | list[str]] = None, + parse_json: bool = True, + **kwargs: _t.Unpack[MessageDataWriterArgsDict], + ) -> None: + """Instantiates the `KafkaDataWriter`. + + Args: + bootstrap_servers: Optional; Kafka broker address(es), for example + `"localhost:9092"`. Falls back to `KAFKA_BOOTSTRAP_SERVERS`. + parse_json: Whether to encode message values as JSON. + **kwargs: Additional keyword arguments for + [`MessageDataWriter`][plugboard.library.MessageDataWriter]. + """ + bootstrap_servers = resolve_argument( + bootstrap_servers, + DI.settings.resolve_sync().kafka.bootstrap_servers, + "bootstrap_servers", + "KAFKA_BOOTSTRAP_SERVERS", + ) + super().__init__(**kwargs) + self._bootstrap_servers = ( + [bootstrap_servers] if isinstance(bootstrap_servers, str) else bootstrap_servers + ) + self._parse_json = parse_json + self._producer: _t.Optional[AIOKafkaProducer] = None + + async def _connect(self) -> None: + """Creates and starts a Kafka producer.""" + producer = AIOKafkaProducer(bootstrap_servers=self._bootstrap_servers) + await producer.start() + self._producer = producer + + async def _disconnect(self) -> None: + """Stops and closes the Kafka producer.""" + if self._producer is not None: + producer = self._producer + self._producer = None + await producer.stop() + + async def _send(self, messages: list[_t.Any]) -> None: + """Sends messages to the Kafka topic. + + Args: + messages: A list of bytes objects to send. + + Raises: + MessageBrokerConnectionError: If the producer is not connected. + MessageBrokerError: If a send failed or the buffer could not be flushed. + """ + if self._producer is None: + raise MessageBrokerConnectionError("Kafka producer is not connected") + try: + # `send` returns a future that resolves once the broker acknowledges the + # record; awaiting them together keeps the producer's batching intact. + await asyncio.gather( + *(self._producer.send(self._topic, value=msg_data) for msg_data in messages) + ) + await self._producer.flush() + 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 send. + """ + return encode_records_bytes(data, self._parse_json) diff --git a/plugboard/library/message_reader.py b/plugboard/library/message_reader.py new file mode 100644 index 00000000..1b22ec06 --- /dev/null +++ b/plugboard/library/message_reader.py @@ -0,0 +1,254 @@ +"""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 IOStreamClosedError, NoMoreDataException +from plugboard.library.data_reader import DataReader +from plugboard.schemas import ComponentArgsDict +from plugboard.utils.retry import RetryPolicy, attempt_reconnect, with_retry + + +class MessageDataReaderArgsDict(ComponentArgsDict): + """Specification of the `MessageDataReader` constructor arguments. + + Attributes: + field_names: The names of the fields to read from messages. + topic: The topic/queue/subscription to read from. + chunk_size: Optional; The number of messages to fetch per batch. + retry_policy: Optional; The backoff policy for retrying failed broker calls. + idle_poll_delay: Optional; Delay in seconds between polls that return no + messages. + """ + + field_names: list[str] + topic: str + chunk_size: _t.NotRequired[int | None] + retry_policy: _t.NotRequired[RetryPolicy] + idle_poll_delay: _t.NotRequired[float] + + +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, + **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. + **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._pending_ack: list[_t.Any] = [] + self._connection_lock = asyncio.Lock() + + @abstractmethod + async def _connect(self) -> None: + """Establishes connection to the message broker. + + Raises: + MessageBrokerConnectionError: If connection cannot be established. + """ + 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. + MessageBrokerConnectionError: If all retries are exhausted. + """ + return await with_retry( + self._receive, + self._reconnect, + policy=self._retry_policy, + 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. + + Acknowledgment waits for the connection lock, so it never runs against a client + that a concurrent reconnect is replacing. + """ + if not self._pending_ack: + return + async with self._connection_lock: + await self._ack(self._pending_ack) + 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. + + Raises: + IOStreamClosedError: If the source is exhausted. + """ + while not self._records_available: + try: + await self._fetch_chunk() + except NoMoreDataException: + await self._ack_pending() + await self.io.close() + raise IOStreamClosedError("No more messages from broker") from None + 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..6309b497 --- /dev/null +++ b/plugboard/library/message_writer.py @@ -0,0 +1,228 @@ +"""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 json +import typing as _t + +from plugboard.component import IOController +from plugboard.library.data_writer import DataWriter +from plugboard.schemas import ComponentArgsDict +from plugboard.utils.retry import RetryPolicy, attempt_reconnect, with_retry + + +class MessageDataWriterArgsDict(ComponentArgsDict): + """Specification of the `MessageDataWriter` constructor arguments. + + Attributes: + 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 each send. + retry_policy: Optional; The backoff policy for retrying failed broker calls. + """ + + field_names: list[str] + topic: str + chunk_size: _t.NotRequired[int | None] + retry_policy: _t.NotRequired[RetryPolicy] + + +def iter_records(data: dict[str, deque]) -> _t.Iterator[dict[str, _t.Any]]: + """Iterates over the complete records held in a field buffer. + + Zipping the deques walks each field once, which keeps record building linear in + the number of buffered values - indexing the deques per row would be quadratic, + and with the default `chunk_size` the buffer holds the whole run. + + 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(), + **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. + **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 + + @abstractmethod + async def _connect(self) -> None: + """Establishes connection to the message broker. + + Raises: + MessageBrokerConnectionError: If connection cannot be established. + """ + 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: + MessageBrokerConnectionError: If all retries are exhausted. + """ + await with_retry( + lambda: self._send(data), + self._reconnect, + policy=self._retry_policy, + logger=self._logger, + description="sending messages", + ) + + async def _reconnect(self) -> None: + """Attempts to reconnect to the message broker. + + No lock is needed here: unlike a reader, a writer never acknowledges + concurrently, and sends are serialised through `self._task`. + """ + 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..e8d398aa 100644 --- a/plugboard/utils/__init__.py +++ b/plugboard/utils/__init__.py @@ -9,7 +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.settings import Settings +from plugboard.utils.settings import Settings, resolve_argument __all__ = [ @@ -19,6 +19,7 @@ "gather_except", "gen_rand_str", "is_on_ray_worker", + "resolve_argument", "run_coro_sync", "ClassRegistry", "DI", diff --git a/plugboard/utils/retry.py b/plugboard/utils/retry.py new file mode 100644 index 00000000..112d9515 --- /dev/null +++ b/plugboard/utils/retry.py @@ -0,0 +1,142 @@ +"""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 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) + + +async def _backoff_and_reconnect( + policy: RetryPolicy, + logger: structlog.BoundLogger, + description: str, + attempt: int, + error: Exception, + reconnect: Callable[[], Awaitable[None]], +) -> None: + """Waits out the backoff delay for a failed attempt and then reconnects. + + A failing reconnect is recorded and swallowed: the next attempt may succeed, and + the caller raises the broker error once the attempts are used up. Without this the + reconnect error would escape the retry loop on the first transient failure. + """ + delay = policy.delay_for(attempt) + logger.warning( + f"Transient error {description}, retrying", + attempt=attempt + 1, + delay=delay, + error=str(error), + ) + await asyncio.sleep(delay) + try: + await reconnect() + except Exception as reconnect_error: # noqa: BLE001 + logger.warning( + f"Reconnection during {description} failed", + attempt=attempt + 1, + error=str(reconnect_error), + ) + + +async def with_retry( + operation: Callable[[], Awaitable[_T]], + reconnect: Callable[[], Awaitable[None]], + *, + policy: RetryPolicy, + 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. + + Args: + operation: The coroutine function to run. + reconnect: The coroutine function to call between attempts. + policy: Backoff parameters, including the retry bound. + 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 error raised by the final attempt, if every attempt failed. + """ + for attempt in range(policy.max_retries): + try: + return await operation() + except (NoMoreDataException, MessageBrokerPermanentError): + raise + except Exception as error: # noqa: BLE001 + await _backoff_and_reconnect(policy, logger, description, attempt, error, reconnect) + # Final attempt: no backoff, so its error propagates unchanged. + return await operation() diff --git a/plugboard/utils/settings.py b/plugboard/utils/settings.py index 613f02a2..93c6c683 100644 --- a/plugboard/utils/settings.py +++ b/plugboard/utils/settings.py @@ -57,6 +57,42 @@ 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 _KafkaSettings(BaseSettings): + """Apache Kafka settings. + + Attributes: + bootstrap_servers: Kafka broker address(es). + """ + + model_config = SettingsConfigDict(env_prefix="KAFKA_") + + bootstrap_servers: _t.Optional[str] = None + + class Settings(BaseSettings): """Settings for Plugboard. @@ -69,6 +105,9 @@ class Settings(BaseSettings): status checks. rabbitmq: RabbitMQ settings. redis: Redis settings. + gcp_pubsub: Google Cloud PubSub settings. + aws: AWS settings for SNS/SQS messaging. + kafka: Apache Kafka settings. """ model_config = SettingsConfigDict(env_prefix=_ENV_PREFIX) @@ -80,3 +119,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) + kafka: _KafkaSettings = Field(default_factory=_KafkaSettings) + + +_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 resolved is None: + 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 c93e5274..b0ad7b72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,11 @@ 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"] +kafka = ["aiokafka>=0.11,<1"] llm = [ "llama-index-core>=0.12.30,<1", "llama-index-llms-openai>=0.3.33,<1", @@ -75,7 +78,12 @@ 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", + "aiokafka>=0.11,<1", "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/unit/test_aws_messaging_io.py b/tests/unit/test_aws_messaging_io.py new file mode 100644 index 00000000..a145a28d --- /dev/null +++ b/tests/unit/test_aws_messaging_io.py @@ -0,0 +1,388 @@ +"""Unit tests for the AWS SQS/SNS message data implementations.""" + +from __future__ import annotations + +from collections import deque +import contextlib +import typing as _t +from unittest.mock import AsyncMock, MagicMock, call + +from botocore.exceptions import BotoCoreError, ClientError +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 + + +def make_reader(**kwargs: _t.Any) -> AWSSQSDataReader: + """Builds a reader for the fixed test queue.""" + kwargs.setdefault("field_names", ["x", "y"]) + 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"]) + 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) -> dict[str, str]: + """Builds an SQS message dict as returned by `ReceiveMessage`.""" + return {"Body": body, "ReceiptHandle": handle, "MessageId": handle} + + +# --------------------------------------------------------------------------- +# Reader: connection and receiving +# --------------------------------------------------------------------------- + + +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 + ) + assert [msg["ReceiptHandle"] for msg in messages] == ["h1"] + + +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() == [] + + +# --------------------------------------------------------------------------- +# Reader: conversion and acknowledgment +# --------------------------------------------------------------------------- + + +async def test_reader_converts_json_bodies() -> None: + """Tests that JSON message bodies become field buffers.""" + reader = make_reader() + await reader._connect() + 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) + await reader._connect() + converted = await reader._convert([sqs_message("hello", "h1")]) + assert converted == {"data": deque(["hello"])} + + +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", + DeleteMessageRequests=[ + {"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["DeleteMessageRequests"]) for call in calls] == [10, 2] + assert calls[1].kwargs["DeleteMessageRequests"][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_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() + + +# --------------------------------------------------------------------------- +# Reader: error mapping +# --------------------------------------------------------------------------- + + +@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_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() + + +# --------------------------------------------------------------------------- +# Writer +# --------------------------------------------------------------------------- + + +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}'), + call(TopicArn="arn:aws:sns:eu-west-1:1:messages", Message='{"x": 2}'), + ] + + +async def test_writer_converts_fields_to_json_strings() -> None: + """Tests that buffered fields become one JSON message per record.""" + writer = make_writer() + await writer._connect() + 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) + await writer._connect() + 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"]) + + +# --------------------------------------------------------------------------- +# Settings resolution +# --------------------------------------------------------------------------- + + +async def test_region_falls_back_to_settings() -> None: + """Tests that `AWS_REGION` supplies the 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"] + ) + assert reader._region == "ap-south-1" + + +def test_explicit_region_wins_over_settings() -> 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", + ) + assert reader._region == "us-west-2" + + +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, + ) + + +def test_reader_topic_defaults_to_queue_url() -> None: + """Tests that the queue URL identifies the reader's topic.""" + reader = make_reader() + assert reader._topic == "https://sqs.test/1/messages" + + +def test_writer_topic_defaults_to_topic_arn() -> None: + """Tests that the topic ARN identifies the writer's topic.""" + writer = make_writer() + assert writer._topic == "arn:aws:sns:eu-west-1:1:messages" diff --git a/tests/unit/test_file_reader_writer.py b/tests/unit/test_file_reader_writer.py index 859a1fcd..60617ec6 100644 --- a/tests/unit/test_file_reader_writer.py +++ b/tests/unit/test_file_reader_writer.py @@ -30,6 +30,12 @@ def mock_s3_server() -> _t.Generator[None, None, None]: """ server = ThreadedMotoServer(ip_address=S3_IP_ADDRESS, port=S3_PORT) server.start() + if "AWS_CONFIG_FILE" not in os.environ: + # 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 = tempfile.NamedTemporaryFile(prefix="plugboard-empty-aws-", delete=False) + os.environ["AWS_CONFIG_FILE"] = empty_config.name + os.environ["AWS_SHARED_CREDENTIALS_FILE"] = empty_config.name 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: diff --git a/tests/unit/test_gcp_pubsub_io.py b/tests/unit/test_gcp_pubsub_io.py new file mode 100644 index 00000000..b037c954 --- /dev/null +++ b/tests/unit/test_gcp_pubsub_io.py @@ -0,0 +1,361 @@ +"""Unit tests for the Google Cloud PubSub message data implementations.""" + +from __future__ import annotations + +from collections import deque +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 +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 + + +def make_message(data: bytes) -> MagicMock: + """Builds a PubSub `ReceivedMessage` double carrying `data`.""" + wrapper = MagicMock() + wrapper.message.data = data + wrapper.ack_id = f"ack-{data!r}" + 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"]) + 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"]) + return GCPPubSubDataWriter( + name="gcp-writer", + project_id="test-project", + topic_id="test-topic", + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# Reader +# --------------------------------------------------------------------------- + + +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_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(subscriber: MagicMock) -> None: + """Tests that JSON payloads become field buffers keyed by the declared fields.""" + reader = make_reader() + await reader._connect() + 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) + await reader._connect() + converted = await reader._convert([make_message(b"hello")]) + assert converted == {"data": deque([b"hello"])} + + +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_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), + (gcp_exceptions.DeadlineExceeded("late"), MessageBrokerTransientError), + ], +) +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_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() -> None: + """Tests that `GCP_PUBSUB_PROJECT_ID` supplies the 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"], + ) + assert reader._subscription_path == "projects/settings-project/subscriptions/test-subscription" + + +# --------------------------------------------------------------------------- +# Writer +# --------------------------------------------------------------------------- + + +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.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(publisher: MagicMock) -> None: + """Tests that buffered fields become one JSON message per record.""" + writer = make_writer() + await writer._connect() + 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(publisher: MagicMock) -> None: + """Tests that a raw writer publishes the first field's value.""" + writer = make_writer(field_names=["x"], parse_json=False) + await writer._connect() + 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_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_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_kafka_io.py b/tests/unit/test_kafka_io.py new file mode 100644 index 00000000..425ab396 --- /dev/null +++ b/tests/unit/test_kafka_io.py @@ -0,0 +1,372 @@ +"""Unit tests for the Apache Kafka message data implementations.""" + +from __future__ import annotations + +from collections import deque +import typing as _t +from unittest.mock import AsyncMock, MagicMock, call + +from aiokafka.errors import ( + KafkaConnectionError, + RecordTooLargeError, + TopicAuthorizationFailedError, +) +from aiokafka.structs import OffsetAndMetadata, TopicPartition +import pytest + +from plugboard.exceptions import ( + MessageBrokerConnectionError, + MessageBrokerPermanentError, + MessageBrokerTransientError, +) +from plugboard.library import kafka_io +from plugboard.library.kafka_io import KafkaDataReader, KafkaDataWriter +from plugboard.utils.settings import Settings +from tests import conftest + + +def consumer_record(topic: str, partition: int, offset: int, value: bytes) -> MagicMock: + """Builds a Kafka `ConsumerRecord` double.""" + record = MagicMock(name="consumer_record") + record.topic = topic + record.partition = partition + record.offset = offset + record.value = value + return record + + +@pytest.fixture +def consumer(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Replaces `AIOKafkaConsumer` with a double that records its construction.""" + client = MagicMock(name="consumer") + client.start = AsyncMock() + client.stop = AsyncMock() + client.commit = AsyncMock() + client.getmany = AsyncMock(return_value={}) + factory = MagicMock(name="AIOKafkaConsumer", return_value=client) + monkeypatch.setattr(kafka_io, "AIOKafkaConsumer", factory) + client.factory = factory + return client + + +@pytest.fixture +def producer(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Replaces `AIOKafkaProducer` with a double that records its construction.""" + client = MagicMock(name="producer") + client.start = AsyncMock() + client.stop = AsyncMock() + client.send = AsyncMock() + client.flush = AsyncMock() + client.send_and_wait = AsyncMock() + factory = MagicMock(name="AIOKafkaProducer", return_value=client) + monkeypatch.setattr(kafka_io, "AIOKafkaProducer", factory) + client.factory = factory + return client + + +def make_reader(**kwargs: _t.Any) -> KafkaDataReader: + """Builds a reader for the fixed test topic/group.""" + kwargs.setdefault("field_names", ["x", "y"]) + return KafkaDataReader( + name="kafka-reader", + topic="test-topic", + group_id="test-group", + bootstrap_servers="localhost:9092", + **kwargs, + ) + + +def make_writer(**kwargs: _t.Any) -> KafkaDataWriter: + """Builds a writer for the fixed test topic.""" + kwargs.setdefault("field_names", ["x", "y"]) + return KafkaDataWriter( + name="kafka-writer", + topic="test-topic", + bootstrap_servers="localhost:9092", + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# Reader: connection and consuming +# --------------------------------------------------------------------------- + + +async def test_reader_starts_consumer_on_topic_group(consumer: MagicMock) -> None: + """Tests the consumer is built for the topic, group and batch size.""" + reader = make_reader(chunk_size=5) + await reader._connect() + + consumer.factory.assert_called_once_with( + "test-topic", + bootstrap_servers=["localhost:9092"], + group_id="test-group", + auto_offset_reset="earliest", + enable_auto_commit=False, + max_poll_records=5, + ) + consumer.start.assert_awaited_once_with() + + +async def test_reader_stops_consumer_on_disconnect(consumer: MagicMock) -> None: + """Tests that teardown stops the consumer, releasing its group membership.""" + reader = make_reader() + await reader._connect() + await reader._disconnect() + consumer.stop.assert_awaited_once_with() + + +async def test_reader_flattens_records_from_partitions(consumer: MagicMock) -> None: + """Tests that records from every assigned partition are returned.""" + consumer.getmany.return_value = { + TopicPartition("test-topic", 0): [consumer_record("test-topic", 0, 1, b"{}")], + TopicPartition("test-topic", 1): [ + consumer_record("test-topic", 1, 7, b"{}"), + consumer_record("test-topic", 1, 8, b"{}"), + ], + } + reader = make_reader() + await reader._connect() + + messages = await reader._receive() + + consumer.getmany.assert_awaited_once_with(timeout_ms=30_000, max_records=10) + assert [(r.partition, r.offset) for r in messages] == [(0, 1), (1, 7), (1, 8)] + + +async def test_reader_empty_poll_returns_no_messages(consumer: MagicMock) -> None: + """Tests that a timed-out poll reads as 'nothing yet', not as an exhausted topic. + + A consumer catching up between messages must keep running, so an empty poll must + not raise `NoMoreDataException`. + """ + consumer.getmany.return_value = {} + reader = make_reader() + await reader._connect() + assert await reader._receive() == [] + + +async def test_reader_converts_json_values(consumer: MagicMock) -> None: + """Tests that JSON record values become field buffers.""" + reader = make_reader() + await reader._connect() + batch = [ + consumer_record("test-topic", 0, 1, b'{"x": 1, "y": "a"}'), + consumer_record("test-topic", 0, 2, b'{"x": 2, "y": "b"}'), + ] + assert await reader._convert(batch) == {"x": deque([1, 2]), "y": deque(["a", "b"])} + + +async def test_reader_non_json_value_uses_data_field(consumer: MagicMock) -> None: + """Tests that a raw value is exposed through the `data` field.""" + reader = make_reader(field_names=["data"], parse_json=False) + await reader._connect() + converted = await reader._convert([consumer_record("test-topic", 0, 1, b"hello")]) + assert converted == {"data": deque(["hello"])} + + +# --------------------------------------------------------------------------- +# Reader: offset commits +# --------------------------------------------------------------------------- + + +async def test_reader_commits_offsets_of_processed_records(consumer: MagicMock) -> None: + """Tests that an ack commits the offset *after* each processed record. + + Committing the consumer position instead of these offsets would mark records that + were never processed as done, and they would never be redelivered. + """ + reader = make_reader() + await reader._connect() + records = [ + consumer_record("test-topic", 0, 3, b"{}"), + consumer_record("test-topic", 0, 4, b"{}"), + ] + + await reader._ack(records) + + consumer.commit.assert_awaited_once_with( + offsets={TopicPartition("test-topic", 0): OffsetAndMetadata(5, "")} + ) + + +async def test_reader_commits_highest_offset_per_partition(consumer: MagicMock) -> None: + """Tests that out-of-order records commit the furthest offset for each partition.""" + reader = make_reader() + await reader._connect() + records = [ + consumer_record("test-topic", 1, 2, b"{}"), + consumer_record("test-topic", 0, 10, b"{}"), + consumer_record("test-topic", 1, 5, b"{}"), + consumer_record("test-topic", 0, 8, b"{}"), + ] + + await reader._ack(records) + + committed = consumer.commit.await_args.kwargs["offsets"] + assert committed == { + TopicPartition("test-topic", 0): OffsetAndMetadata(11, ""), + TopicPartition("test-topic", 1): OffsetAndMetadata(6, ""), + } + + +async def test_reader_commits_nothing_without_records(consumer: MagicMock) -> None: + """Tests that an empty batch does not issue a commit.""" + reader = make_reader() + await reader._connect() + await reader._ack([]) + consumer.commit.assert_not_awaited() + + +async def test_reader_without_connection_raises_connection_error(consumer: MagicMock) -> None: + """Tests that consuming before connecting fails loudly.""" + reader = make_reader() + with pytest.raises(MessageBrokerConnectionError): + await reader._receive() + with pytest.raises(MessageBrokerConnectionError): + await reader._ack([]) + + +# --------------------------------------------------------------------------- +# Reader: error mapping +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("error", "expected"), + [ + (TopicAuthorizationFailedError("denied"), MessageBrokerPermanentError), + (RecordTooLargeError("too big"), MessageBrokerPermanentError), + (KafkaConnectionError("down"), MessageBrokerTransientError), + ], +) +async def test_reader_maps_kafka_errors( + consumer: MagicMock, error: Exception, expected: type[Exception] +) -> None: + """Tests that Kafka failures land on the right broker exception type.""" + consumer.getmany.side_effect = error + reader = make_reader() + await reader._connect() + + with pytest.raises(expected): + await reader._receive() + + +async def test_reader_unclassified_error_propagates(consumer: MagicMock) -> None: + """Tests that an unknown error is not rewritten into a broker exception.""" + consumer.getmany.side_effect = ValueError("unexpected") + reader = make_reader() + await reader._connect() + + with pytest.raises(ValueError, match="unexpected"): + await reader._receive() + + +# --------------------------------------------------------------------------- +# Writer +# --------------------------------------------------------------------------- + + +async def test_writer_starts_producer(producer: MagicMock) -> None: + """Tests that the producer is created and started.""" + writer = make_writer() + await writer._connect() + producer.factory.assert_called_once_with(bootstrap_servers=["localhost:9092"]) + producer.start.assert_awaited_once_with() + + +async def test_writer_stops_producer_on_disconnect(producer: MagicMock) -> None: + """Tests that teardown stops the producer.""" + writer = make_writer() + await writer._connect() + await writer._disconnect() + producer.stop.assert_awaited_once_with() + + +async def test_writer_sends_batch_then_flushes_once(producer: MagicMock) -> None: + """Tests that records are submitted together and the buffer flushed once. + + Awaiting each send individually (as `send_and_wait` does) costs one round trip per + record and defeats the producer's batching. + """ + writer = make_writer() + await writer._connect() + + await writer._send([b"one", b"two", b"three"]) + + assert producer.send.await_args_list == [ + call("test-topic", value=b"one"), + call("test-topic", value=b"two"), + call("test-topic", value=b"three"), + ] + producer.send_and_wait.assert_not_awaited() + producer.flush.assert_awaited_once_with() + + +async def test_writer_converts_fields_to_json_bytes(producer: MagicMock) -> None: + """Tests that buffered fields become one JSON message per record.""" + writer = make_writer() + await writer._connect() + 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(producer: MagicMock) -> None: + """Tests that a raw writer publishes the first field's value.""" + writer = make_writer(field_names=["x"], parse_json=False) + await writer._connect() + assert await writer._convert({"x": deque(["hello"])}) == [b"hello"] + + +async def test_writer_send_failure_maps_error(producer: MagicMock) -> None: + """Tests that a failed send surfaces as a broker exception.""" + producer.send.side_effect = KafkaConnectionError("down") + writer = make_writer() + await writer._connect() + + with pytest.raises(MessageBrokerTransientError): + await writer._send([b"one"]) + + +async def test_writer_without_connection_raises_connection_error(producer: MagicMock) -> None: + """Tests that sending before connecting fails loudly.""" + writer = make_writer() + with pytest.raises(MessageBrokerConnectionError): + await writer._send([b"one"]) + + +# --------------------------------------------------------------------------- +# Settings resolution +# --------------------------------------------------------------------------- + + +async def test_bootstrap_servers_fall_back_to_settings() -> None: + """Tests that `KAFKA_BOOTSTRAP_SERVERS` supplies the brokers when not passed.""" + settings = Settings.model_validate({"kafka": {"bootstrap_servers": "kafka:9092"}}) + with conftest.override_settings(settings): + writer = KafkaDataWriter(name="kafka-writer", topic="test-topic", field_names=["x"]) + assert writer._bootstrap_servers == ["kafka:9092"] + + +def test_bootstrap_servers_accepts_a_list() -> None: + """Tests that an explicit list of brokers is kept as given.""" + reader = KafkaDataReader( + name="kafka-reader", + topic="test-topic", + group_id="test-group", + field_names=["x"], + bootstrap_servers=["a:9092", "b:9092"], + ) + assert reader._bootstrap_servers == ["a:9092", "b:9092"] + + +def test_missing_bootstrap_servers_names_the_environment_variable() -> None: + """Tests that unresolvable brokers report what to configure.""" + with conftest.override_settings(Settings.model_validate({})): + with pytest.raises(ValueError, match="KAFKA_BOOTSTRAP_SERVERS"): + KafkaDataWriter( + name="kafka-writer", + topic="test-topic", + field_names=["x"], + bootstrap_servers=None, + ) diff --git a/tests/unit/test_message_data_reader.py b/tests/unit/test_message_data_reader.py new file mode 100644 index 00000000..27befb9a --- /dev/null +++ b/tests/unit/test_message_data_reader.py @@ -0,0 +1,430 @@ +"""Unit tests for the `MessageDataReader` base class.""" + +from __future__ import annotations + +from collections import deque +from types import SimpleNamespace +import typing as _t + +import pytest + +from plugboard.exceptions import ( + IOStreamClosedError, + MessageBrokerPermanentError, + MessageBrokerTransientError, + NoMoreDataException, +) +from plugboard.library.message_reader import MessageDataReader +from plugboard.utils import retry as retry_utils +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") + + +@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 + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +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() + + +# --------------------------------------------------------------------------- +# Reading records +# --------------------------------------------------------------------------- + + +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() + + +# --------------------------------------------------------------------------- +# Empty polls are not the end of the stream +# --------------------------------------------------------------------------- + + +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() + + +# --------------------------------------------------------------------------- +# Acknowledgment timing +# --------------------------------------------------------------------------- + + +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 == [] + + +# --------------------------------------------------------------------------- +# Retry and reconnection +# --------------------------------------------------------------------------- + + +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 costs an attempt instead of ending the retrying. + + During a network partition the read and the reconnect can both fail; the reader must + still use up its retries and then report the broker error. + """ + 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 == 3 # All three attempts ran, despite the reconnect failures. + assert reader.connects == 3 # Initial connect plus one per retry. + assert backoff_delays == [1.0, 2.0] + await reader.destroy() diff --git a/tests/unit/test_message_data_writer.py b/tests/unit/test_message_data_writer.py new file mode 100644 index 00000000..a1926e10 --- /dev/null +++ b/tests/unit/test_message_data_writer.py @@ -0,0 +1,358 @@ +"""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.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. + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + + +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]) +# --------------------------------------------------------------------------- +# Batching +# --------------------------------------------------------------------------- + + +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 + + +# --------------------------------------------------------------------------- +# Retry and reconnection +# --------------------------------------------------------------------------- + + +async def test_transient_send_failure_reconnects_and_recovers() -> 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 + + +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() -> 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. + writer._task = None + await writer.destroy() + + +async def test_reconnect_failure_does_not_abort_retries() -> None: + """Tests that a failing reconnect costs an attempt instead of ending the retrying.""" + 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 == 3 # Every attempt ran despite the reconnect failures. + assert writer.connects == 3 + writer._task = None + await writer.destroy() + + +# --------------------------------------------------------------------------- +# Shared record encoding helpers +# --------------------------------------------------------------------------- + + +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) == 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) == 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] diff --git a/uv.lock b/uv.lock index d783bfc8..93c2b18c 100644 --- a/uv.lock +++ b/uv.lock @@ -52,7 +52,7 @@ wheels = [ [[package]] name = "aiobotocore" -version = "3.4.0" +version = "3.9.2" 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/a5/95/4f7e0453d5e81bdb1b40ecf09f135bb88e12ce27fdb3efa125022dcca5d7/aiobotocore-3.9.2.tar.gz", hash = "sha256:8e32238c5bd77717ab1ac90077ff51ab141d025528115ccc7cc9b50b2d176e5f", size = 524343, upload-time = "2026-10-01T02:01:59.522Z" } 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/09/ab/c8f982c4dfaab49200e31820a4ffe6275356f7dcaaa1024735d8f8b4e048/aiobotocore-3.9.2-py3-none-any.whl", hash = "sha256:363b4892423b272eb84afbb58000da0be6ccd0cf8a68b98438c957d05b841efc", size = 102571, upload-time = "2026-10-01T02:01:57.883Z" }, ] [[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]] @@ -195,6 +210,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, ] +[[package]] +name = "aiokafka" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/5f/dfc1180fd22d1acdc91949ec36e97199c43742dacb057cb8efed3679ed04/aiokafka-0.14.0.tar.gz", hash = "sha256:8ffdc945798ba4d3d132b705d4244d0a1f493925efb57c637a2ca88ee82794e1", size = 601374, upload-time = "2026-04-29T10:43:03.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9d/3441db94829f9feb802a2f4052df61c0d1a01272accd174c351d7e9e1f6a/aiokafka-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:284a90d617584d7e42688a181aaa8c2a909d9c658ab9b69c6cf92f4df5c4b320", size = 348458, upload-time = "2026-04-29T10:42:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/a4/10/7297589aac95654596af13301b31da2c9502c80e7e308530ee7a9bd5b9f1/aiokafka-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b4f211d9e03a1fc83871a37eefcf307bc0943ee99adae25aa39bd1722e70747b", size = 351057, upload-time = "2026-04-29T10:42:38.69Z" }, + { url = "https://files.pythonhosted.org/packages/26/4e/5c0aa8db717fff0ffb8f3e16deece8f98ded6ca17c6a543b6b20cc9a7f84/aiokafka-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be517b9b9513eba43ba19961dd770a6e26d08325743093feb47182770d235dd9", size = 1142238, upload-time = "2026-04-29T10:42:39.96Z" }, + { url = "https://files.pythonhosted.org/packages/88/78/322f797b9593a4cc8afd647342fa66b9ad732ee55098e5e084188c6202aa/aiokafka-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:219d2dc66b97b1aaea100697c928024b6a0348b7baa370b824900054bf86916e", size = 1131567, upload-time = "2026-04-29T10:42:41.542Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0a/a45320778385142299a7fc3ae402152ec1f383537130b8aa8e8587742fad/aiokafka-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1086b470f6c452471603a2d9c8d6933739230c75758d777d8d113ff8112bad68", size = 312160, upload-time = "2026-04-29T10:42:42.811Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fb/7802a0ed69200e3e8e8791df06bd6daf9b00523839d045662de4ff061b18/aiokafka-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:bcf3a8f6592d73f45965ca0750bfdfccf2555c8625358175c92f75f2cce1261a", size = 331897, upload-time = "2026-04-29T10:42:43.984Z" }, + { url = "https://files.pythonhosted.org/packages/30/b0/c9384541b2e4cc52a16402fc53fb9d44af0d78d37954cf8c7271c376ad47/aiokafka-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db16e43fac4c1c5006131046c1bf370c580d6ac4495a10ac7778245710943179", size = 345859, upload-time = "2026-04-29T10:42:45.449Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d1/fc266d9f4ffba4f197356c6ffdfbb0fe32e7cb874e240f299935d058ac06/aiokafka-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:32a8e91d88cf3ccf0778927715610d6579888c5f4748db4c2022cda25d628a48", size = 348284, upload-time = "2026-04-29T10:42:47.104Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8e/0c4c270786dac79f3fca74c6166c3a25b61b0d26132be0d69f0d7f206f0a/aiokafka-0.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aad4a575a506e7784e25e430f27026fe2f4378560b21b7f4e8c9a54f0d06eaee", size = 1117867, upload-time = "2026-04-29T10:42:48.394Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7f/3b89fbd0a3be9edfd5b51e20bb5cd695c851219b63c501c051cf84367fa9/aiokafka-0.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75e4a003502c9c3b5c705fa7c00d634ba146bf38fa5d525b80bb6ff6e3e779fe", size = 1108860, upload-time = "2026-04-29T10:42:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/849aba75cff93277bf6bf8b630de79e902949ff7ec48e4b12a64e6e32cae/aiokafka-0.14.0-cp313-cp313-win32.whl", hash = "sha256:a128e213cbc2bce0ea3db65a68920e52cebeeb8209bf001ac7aa022a8bd54d7d", size = 310889, upload-time = "2026-04-29T10:42:52.038Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e5/52eab8f8515d23da7b5d90e2c5ba10eab9494a0314f749e3f73e003f4a50/aiokafka-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:d6fa16bef3544be87bd1a7a8317b9d85e3da59f3202326d9ff22735ed052746e", size = 329470, upload-time = "2026-04-29T10:42:53.536Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/984803315fe2b883ea6e08b1d9c8a752bd5c16e966d8714bacc67c72c417/aiokafka-0.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5d70615d1530ad19d0c4da8d87abaec0a12b9fdaabffdcd4e400efa0c50ef80c", size = 346672, upload-time = "2026-04-29T10:42:55.267Z" }, + { url = "https://files.pythonhosted.org/packages/49/df/da314966b7f3c3117bd78b082563cb03dbe3007848cb8f4b0932faf390a0/aiokafka-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7e2392360c370b1ba6564c57d2889e154ecdb43157a8f7b7d7afe5e3c02fcc1a", size = 349594, upload-time = "2026-04-29T10:42:56.565Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/160516944ea0e0f68ea78e38f944c52f5248c7c7df26cba22a40b9f25709/aiokafka-0.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:201e38ecc595f9f65a945f1ef9085157ddf28f25cd2e482fd9efa1fcf4638213", size = 1114112, upload-time = "2026-04-29T10:42:57.869Z" }, + { url = "https://files.pythonhosted.org/packages/68/c4/9841118a2157e913e8ebfbc0a2b58f7b60f1f7202040c3e1df8925ed1184/aiokafka-0.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1cd651e1f56571baae306fdd0b5509047ab9625797a24cd75902e139c5a20318", size = 1098571, upload-time = "2026-04-29T10:42:59.356Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a1/0af8a37849a4108ae227f46c4c62f6beab31863cf66ba318fb73b0be5b26/aiokafka-0.14.0-cp314-cp314-win32.whl", hash = "sha256:128127eb96dab98150b636bb5f480c80e15f02f82a118eec206a521c8cf7cf7c", size = 314107, upload-time = "2026-04-29T10:43:01.111Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/fb46c65f758900c71d0f1c73b7802720f99cabcb1f4a11676573f9bc1b8f/aiokafka-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa385039aa9b235359319bbdcf48c9c86a75d81c9c547d645056d00361238903", size = 333320, upload-time = "2026-04-29T10:43:02.424Z" }, +] + [[package]] name = "aiormq" version = "7.0.0" @@ -367,6 +413,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -647,30 +702,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.84" +version = "1.43.106" 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/49/01/97aaee4d3e94467983a0c1b986ed4f4da48960d7ebc948e7d739c818cb59/boto3-1.43.106.tar.gz", hash = "sha256:c11ad4c429a983493ba10014c7af9831a455c2c0eea91c1cefff74530e480277", size = 112730, upload-time = "2026-09-30T19:36:41.684Z" } 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/72/d8/a01764ffd32fbcd66c5575eb85764ae9ea7b7d03dc591592e132c43af1f5/boto3-1.43.106-py3-none-any.whl", hash = "sha256:fdf2e304f9e8864b8613591b18b36ee995560c9666033622eda1cb9057866880", size = 140045, upload-time = "2026-09-30T19:36:40.228Z" }, ] [[package]] name = "botocore" -version = "1.42.84" +version = "1.43.106" 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/11/b9/10ca68d0092895d5ea60f485a61a9840d5aff9d732c66ed60da53a20b1d4/botocore-1.43.106.tar.gz", hash = "sha256:006870b3b4e40547232ad12c3bb4faec91bbbe0659aafaa3b7fa48a112c4ee97", size = 16271399, upload-time = "2026-09-30T19:36:35.836Z" } 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/a8/eb/c51d3b54dfaa9267a687359d946dc2a1ca6a27fb4d381773e59b7bd72bf4/botocore-1.43.106-py3-none-any.whl", hash = "sha256:c1fb8818f9957cb5037f73db34ac1a12ba43562e31d6e7f9cb7e5fc64e1dba78", size = 15965506, upload-time = "2026-09-30T19:36:32.625Z" }, ] [[package]] @@ -1481,6 +1536,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.42.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/41/60/21e7de2ae629d8a025838b4e53f197aea2b45d878c6e37f222187b2ecf84/google_cloud_pubsub-2.42.0.tar.gz", hash = "sha256:92ed998c41ce7d4f6b5b7c950df8291abd27c4d50116714e5c46fac47313e74d", size = 397642, upload-time = "2026-10-01T18:17:44.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7d/a3db64849bc41a52b9c8625f5c76fbdbba02ad2d89b91d38c80a304ade51/google_cloud_pubsub-2.42.0-py3-none-any.whl", hash = "sha256:6eff5d2155b5f6967885b0e7f3da6fda91d2cfb15227fffeaef39b7339044a3d", size = 322310, upload-time = "2026-10-01T18:11:47.708Z" }, +] + [[package]] name = "google-cloud-storage" version = "3.12.0" @@ -1812,8 +1887,8 @@ name = "httpcore2" version = "2.12.0" 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/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -3833,12 +3908,21 @@ dependencies = [ aws = [ { name = "s3fs" }, ] +aws-messaging = [ + { name = "aiobotocore" }, +] azure = [ { name = "adlfs" }, ] gcp = [ { name = "gcsfs" }, ] +gcp-pubsub = [ + { name = "google-cloud-pubsub" }, +] +kafka = [ + { name = "aiokafka" }, +] llm = [ { name = "llama-index-core" }, { name = "llama-index-llms-openai" }, @@ -3860,9 +3944,12 @@ websockets = [ [package.dev-dependencies] all = [ + { name = "aiobotocore" }, { name = "aiofile" }, + { name = "aiokafka" }, { name = "aiosqlite" }, { name = "coverage" }, + { name = "google-cloud-pubsub" }, { name = "hatch" }, { name = "hatch-vcs" }, { name = "ipython" }, @@ -3919,9 +4006,12 @@ docs = [ { name = "mkdocstrings", extra = ["python"] }, ] test = [ + { name = "aiobotocore" }, { name = "aiofile" }, + { name = "aiokafka" }, { name = "aiosqlite" }, { name = "coverage" }, + { name = "google-cloud-pubsub" }, { name = "llama-index-core" }, { name = "llama-index-llms-openai" }, { name = "moto", extra = ["server"] }, @@ -3944,11 +4034,14 @@ 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 = "aiokafka", marker = "extra == 'kafka'", specifier = ">=0.11,<1" }, { 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" }, @@ -3974,13 +4067,16 @@ 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", "kafka", "llm", "omq", "ray", "redis", "websockets"] [package.metadata.requires-dev] all = [ + { name = "aiobotocore", specifier = ">=3.4,<4" }, { name = "aiofile", specifier = ">=3.9,<4" }, + { name = "aiokafka", specifier = ">=0.11,<1" }, { 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" }, @@ -4037,9 +4133,12 @@ docs = [ { name = "mkdocstrings", extras = ["python"], specifier = ">=0.25,<2" }, ] test = [ + { name = "aiobotocore", specifier = ">=3.4,<4" }, { name = "aiofile", specifier = ">=3.9,<4" }, + { name = "aiokafka", specifier = ">=0.11,<1" }, { 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" }, @@ -5237,14 +5336,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]] @@ -5252,8 +5351,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 = [ @@ -6164,66 +6263,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]]