diff --git a/README.md b/README.md index fdd0230..62a2d73 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ Here is an example of how to use the SQS broker with the S3 backend: ```python import asyncio -from taskiq_sqs import S3Bucket, S3ResultBackend, SQSBroker +from taskiq_sqs import S3ResultBackend, SQSBroker +from taskiq_sqs.types import S3Bucket, SQSQueue -QUEUE_NAME = "my-queue" broker = SQSBroker( - "http://localhost:4566/000000000000/my-queue", # specify existing queue - sqs_region_override="us-east-1" + queues=SQSQueue(name="my-queue"), # specify an existing queue + endpoint_url="http://localhost:4566", + aws_region_name="us-east-1", ).with_result_backend( S3ResultBackend( bucket=S3Bucket(name="response-bucket") # by default backend will create bucket for you if it does not exist @@ -49,48 +50,38 @@ How to run: - run worker first with `taskiq worker examples.example_broker:broker` - after that run broker to create a task and wait for result: `python examples/example_broker.py` -## Message expiration +## Multiple queues -If you set the `sqs_expiry` label to a unix timestamp, the message will be discarded if the worker receives it after that time. +`SQSBroker` accepts a single queue or a list of them. The first queue is the default one, used whenever a task doesn't say otherwise. To send a task to a specific queue, set the `sqs_queue` label with that queue's name: ```python -import asyncio from taskiq_sqs import SQSBroker +from taskiq_sqs.types import SQSQueue -broker = SQSBroker("http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/my-queue") - -@broker.task -async def add_one(value: int) -> int: - return value + 1 - - -async def main() -> None: - # Never forget to call startup in the beginning. - await broker.startup() - # Send the task to the broker. - task = await add_one.kiq(1) - # Wait for the result. (result backend must be configured) - result = await task.wait_result(timeout=2) - print(f"Task execution took: {result.execution_time} seconds.") - if not result.is_err: - print(f"Returned value: {result.return_value}") - else: - print("Error found while executing task.") - await broker.shutdown() +broker = SQSBroker( + queues=[ + SQSQueue(name="default-queue"), + SQSQueue(name="high-priority-queue", wait_time_seconds=5), + ], +) -if __name__ == "__main__": - asyncio.run(main()) +@broker.task(sqs_queue="high-priority-queue") # "sqs_queue" is taskiq_sqs.broker.SQS_QUEUE_LABEL +async def urgent_task() -> None: + ... ``` +A worker started against this broker consumes from every configured queue at once. Passing a queue name through the `sqs_queue` label that isn't configured on the broker raises `UnknownQueueError`. + ## Offloading large messages to S3 SQS messages are limited to 256 KiB. `S3OffloadMiddleware` transparently uploads task payloads that exceed a configurable threshold to S3 before sending them to the queue, and replaces the message with a reference to the uploaded object. The worker downloads the original payload back from S3 before executing the task, and (by default) removes it from S3 afterwards. ```python import asyncio -from taskiq_sqs import S3Bucket, S3OffloadMiddleware, SQSBroker +from taskiq_sqs import S3OffloadMiddleware, SQSBroker +from taskiq_sqs.types import S3Bucket, SQSQueue -broker = SQSBroker("http://localhost:4566/000000000000/my-queue") +broker = SQSBroker(queues=SQSQueue(name="my-queue")) broker.add_middlewares( S3OffloadMiddleware( bucket=S3Bucket(name="offload-bucket"), # created automatically if it doesn't exist diff --git a/examples/example_broker.py b/examples/example_broker.py index 1659ac4..ca66fd8 100644 --- a/examples/example_broker.py +++ b/examples/example_broker.py @@ -8,10 +8,11 @@ import asyncio +import capo_sqs import dotenv -from aiobotocore.session import get_session -from taskiq_sqs import S3Bucket, S3ResultBackend, SQSBroker +from taskiq_sqs import S3ResultBackend, SQSBroker +from taskiq_sqs.types import S3Bucket, SQSQueue dotenv.load_dotenv() @@ -22,7 +23,7 @@ broker = SQSBroker( - queue_name=QUEUE_NAME, + queues=SQSQueue(name=QUEUE_NAME), endpoint_url=ENDPOINT_URL, aws_region_name=AWS_REGION, ).with_result_backend( @@ -42,13 +43,8 @@ async def i_love_aws() -> None: async def ensure_queue_exists() -> None: - session = get_session() - async with session.create_client( - "sqs", - region_name=AWS_REGION, - endpoint_url=ENDPOINT_URL, - ) as sqs: - await sqs.create_queue(QueueName=QUEUE_NAME) + async with capo_sqs.AsyncSQSClient(region=AWS_REGION, endpoint=ENDPOINT_URL) as sqs: + await sqs.create_queue(queue_name=QUEUE_NAME) async def main() -> None: diff --git a/pyproject.toml b/pyproject.toml index 0d1a363..7fa8372 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ keywords = ["taskiq", "broker", "aws", "sqs"] requires-python = ">=3.11" dependencies = [ "taskiq>=0.12.6", - "aiobotocore>=2.13.3", "capo-s3>=0.15.0", "capo-sqs>=0.6.0", ] @@ -57,7 +56,6 @@ lint = [ "ruff>=0.16.7", "zizmor>=1.30.1", "mypy>=2.3.1", - "types-aiobotocore[essential]>=3.7.0", ] examples = [ "python-dotenv>=1.2.3", diff --git a/src/taskiq_sqs/broker.py b/src/taskiq_sqs/broker.py index efb179f..89be6b2 100644 --- a/src/taskiq_sqs/broker.py +++ b/src/taskiq_sqs/broker.py @@ -1,29 +1,24 @@ +import asyncio import contextlib import logging -from collections.abc import AsyncGenerator, Awaitable, Callable, Generator -from typing import TYPE_CHECKING +from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Mapping, Sequence +from typing import Any -from aiobotocore.session import get_session -from botocore.exceptions import ClientError +import capo_sqs from taskiq import AsyncBroker from taskiq.acks import AckableMessage from taskiq.message import BrokerMessage from taskiq_sqs import constants -from taskiq_sqs.exceptions import BrokerInitError +from taskiq_sqs.exceptions import BrokerInitError, UnknownQueueError from taskiq_sqs.types import SQSQueue -if TYPE_CHECKING: - from types_aiobotocore_sqs.client import SQSClient - from types_aiobotocore_sqs.type_defs import ( - GetQueueUrlResultTypeDef, - MessageTypeDef, - SendMessageRequestTypeDef, - ) +logger = logging.getLogger(__name__) +SQS_QUEUE_LABEL = "sqs_queue" -logger = logging.getLogger(__name__) +_QueueItem = AckableMessage | BaseException class SQSBroker(AsyncBroker): @@ -31,23 +26,19 @@ class SQSBroker(AsyncBroker): def __init__( self, - queue_name: str, + queues: SQSQueue | Sequence[SQSQueue], endpoint_url: str | None = None, aws_region_name: str = constants.AWS_DEFAULT_REGION, aws_access_key_id: str | None = None, aws_secret_access_key: str | None = None, - wait_time_seconds: int = 0, - max_number_of_messages: int = 1, ) -> None: """Initialize the SQS broker. - :param: queue_name: The name of the SQS queue. - :param: endpoint_url: The SQS endpoint URL. - :param aws_region_name: The AWS region name. - :param aws_access_key_id: The AWS access key ID. - :param aws_secret_access_key: The AWS secret access key. - :param: wait_time_seconds: The wait time used for long polling. - :param: max_number_of_messages: Size of batch to receive from the queue. + :param queues: a single queue configuration, or a sequence of them for multiqueue support.ф + :param endpoint_url: the SQS endpoint URL. + :param aws_region_name: the AWS region name. + :param aws_access_key_id: the AWS access key ID. + :param aws_secret_access_key: the AWS secret access key. """ super().__init__() @@ -56,159 +47,181 @@ def __init__( self._aws_secret_access_key = aws_secret_access_key self._aws_endpoint_url = endpoint_url - self._session = get_session() - self._startup_called = False + self._queues = self._normalize_queues(queues) + self._default_queue_name = self._queues[0]["name"] + self._queues_by_name = {queue["name"]: queue for queue in self._queues} + self._queue_urls: dict[str, str] = {} - self._sqs_queue_url: str | None = None + @staticmethod + def _normalize_queues(queues: SQSQueue | Sequence[SQSQueue]) -> list[SQSQueue]: + queue_list = [queues] if isinstance(queues, Mapping) else list(queues) + if not queue_list: + raise BrokerInitError(details="At least one queue must be configured.") - if max_number_of_messages > constants.MAX_NUMBER_OF_MESSAGES or max_number_of_messages < 1: - raise BrokerInitError(details="MaxNumberOfMessages can be no greater than 10 or less than 1") - self._max_number_of_messages = max_number_of_messages + names = [queue["name"] for queue in queue_list] + if len(names) != len(set(names)): + raise BrokerInitError(details="Queue names must be unique.") - if wait_time_seconds > constants.MAX_WAIT_TIME_SECONDS or wait_time_seconds < 0: - raise BrokerInitError(details="WaitTimeSeconds can be no greater than 20 or less than 0") - self._wait_time_seconds = wait_time_seconds + for queue in queue_list: + max_number_of_messages = queue.get("max_number_of_messages", 1) + if max_number_of_messages > constants.MAX_NUMBER_OF_MESSAGES or max_number_of_messages < 1: + raise BrokerInitError( + details=f"MaxNumberOfMessages for queue '{queue['name']}' can be no greater than 10 or less than 1", + ) + wait_time_seconds = queue.get("wait_time_seconds", 0) + if wait_time_seconds > constants.MAX_WAIT_TIME_SECONDS or wait_time_seconds < 0: + raise BrokerInitError( + details=f"WaitTimeSeconds for queue '{queue['name']}' can be no greater than 20 or less than 0", + ) + return queue_list - try: - self._default_queue: SQSQueue = SQSQueue( - name=queue_name, - max_number_of_messages=self._max_number_of_messages, - wait_time_seconds=self._wait_time_seconds, - ) - except ValueError as error: - raise BrokerInitError(details="Invalid default queue configuration.") from error + def _resolve_queue(self, queue_name: str | None) -> SQSQueue: + name = queue_name or self._default_queue_name + queue = self._queues_by_name.get(name) + if queue is None: + raise UnknownQueueError(queue_name=name) + return queue @contextlib.contextmanager - def _handle_exceptions(self) -> Generator[None, None, None]: + def _handle_exceptions(self, queue_name: str) -> Generator[None, None, None]: """Handle exceptions raised by the SQS client.""" try: yield - except ClientError as e: - error = e.response.get("Error", {}) - code = error.get("Code") - error_message = error.get("Message") - if code == "AWS.SimpleQueueService.NonExistentQueue": - raise BrokerInitError( - details=f"Queue not found {self._default_queue.name}", - ) from e - elif code in ["InvalidParameterValue", "NoSuchBucket"]: - raise BrokerInitError(details=error_message or "") from e - else: - raise BrokerInitError(details=code or "") from e - - async def _get_sqs_client(self) -> "SQSClient": - self._client_context_creator = self._session.create_client( - "sqs", - region_name=self._aws_region, - endpoint_url=self._aws_endpoint_url, - aws_access_key_id=self._aws_access_key_id, - aws_secret_access_key=self._aws_secret_access_key, - ) - return await self._client_context_creator.__aenter__() - - async def _close_client(self) -> None: - """Closes the SQS/S3 client.""" - await self._client_context_creator.__aexit__(None, None, None) - - async def _get_queue_url(self) -> str: - if not self._sqs_queue_url: - with self._handle_exceptions(): - queue_result: GetQueueUrlResultTypeDef = await self._sqs_client.get_queue_url( - QueueName=self._default_queue.name, - ) - self._sqs_queue_url = queue_result["QueueUrl"] - return self._sqs_queue_url + except capo_sqs.errors.QueueDoesNotExist as e: + raise BrokerInitError(details=f"Queue not found {queue_name}") from e + except capo_sqs.errors.ServiceError as e: + raise BrokerInitError(details=e.code or "") from e async def startup(self) -> None: - """Starts the SQS broker and checks that queue exists.""" - self._startup_called = True - self._sqs_client = await self._get_sqs_client() - - queue_url = await self._get_queue_url() - logger.info("Resolved queue '%s' URL: %s", self._default_queue.name, queue_url) + """Starts the SQS broker and checks that every configured queue exists.""" + credentials = None + if self._aws_access_key_id and self._aws_secret_access_key: + credentials = capo_sqs.Credentials( + access_key=self._aws_access_key_id, + secret_key=self._aws_secret_access_key, + ) + self._sqs_client = capo_sqs.AsyncSQSClient( + region=self._aws_region, + endpoint=self._aws_endpoint_url, + credentials=credentials, + ) + await self._sqs_client.__aenter__() + try: + for queue in self._queues: + queue_url = await self._get_queue_url(queue["name"]) + logger.info("Resolved queue '%s' URL: %s", queue["name"], queue_url) + except Exception: + await self._sqs_client.__aexit__(None, None, None) + raise await super().startup() async def shutdown(self) -> None: """Shuts down the SQS broker.""" - await self._close_client() + await self._sqs_client.__aexit__(None, None, None) await super().shutdown() + async def _get_queue_url(self, queue_name: str) -> str: + if queue_name not in self._queue_urls: + with self._handle_exceptions(queue_name): + result = await self._sqs_client.get_queue_url(queue_name=queue_name) + self._queue_urls[queue_name] = result["queue_url"] + return self._queue_urls[queue_name] + async def _build_kick_kwargs( self, message: BrokerMessage, - ) -> "SendMessageRequestTypeDef": + queue_url: str, + ) -> dict[str, Any]: """Build the kwargs for the SQS client kick method. - This function can be extended by the end user to - add additional kwargs in the message delivery. + This function can be extended by the end user to add additional kwargs in the message delivery. :param message: BrokerMessage object. + :param queue_url: URL of the queue the message will be sent to. """ - kwargs: SendMessageRequestTypeDef = { - "QueueUrl": await self._get_queue_url(), - "MessageBody": message.message.decode("utf-8"), + return { + "queue_url": queue_url, + "message_body": message.message.decode("utf-8"), } - return kwargs - - async def _send_message( - self, - message: BrokerMessage, - ) -> None: - """Send a single message. - - :param message: - """ - kwargs = await self._build_kick_kwargs(message) - with self._handle_exceptions(): - await self._sqs_client.send_message(**kwargs) async def kick(self, message: BrokerMessage) -> None: """Kick tasks out from current program to configured SQS queue. + The target queue is picked from the `sqs_queue` label (see `SQS_QUEUE_LABEL`), falling back to the first + configured queue when the label isn't set. + :param message: BrokerMessage object. """ - await self._send_message(message) + queue = self._resolve_queue(message.labels.get(SQS_QUEUE_LABEL)) + queue_url = await self._get_queue_url(queue["name"]) + kwargs = await self._build_kick_kwargs(message, queue_url) + with self._handle_exceptions(queue["name"]): + await self._sqs_client.send_message(**kwargs) def _build_ack_function( self, + queue_name: str, queue_url: str, receipt_handle: str, ) -> Callable[[], Awaitable[None]]: """ This method is used to build an ack for the message. - :param queue_url: queue url where the message is located + :param queue_name: name of the queue where the message is located. + :param queue_url: queue url where the message is located. :param receipt_handle: message to build ack for. """ async def ack() -> None: - with self._handle_exceptions(): + with self._handle_exceptions(queue_name): await self._sqs_client.delete_message( - QueueUrl=queue_url, - ReceiptHandle=receipt_handle, + queue_url=queue_url, + receipt_handle=receipt_handle, ) return ack + async def _poll_queue(self, queue: SQSQueue, incoming: "asyncio.Queue[_QueueItem]") -> None: + """Continuously receive messages from a single queue and forward them to the shared incoming queue.""" + try: + queue_url = await self._get_queue_url(queue["name"]) + while True: + with self._handle_exceptions(queue["name"]): + results = await self._sqs_client.receive_message( + queue_url=queue_url, + max_number_of_messages=queue.get("max_number_of_messages", 1), + wait_time_seconds=queue.get("wait_time_seconds", 0), + ) + for message in results.get("messages", []): + body = message.get("body") + receipt_handle = message.get("receipt_handle") + if body and receipt_handle: + await incoming.put( + AckableMessage( + data=body.encode("utf-8"), + ack=self._build_ack_function(queue["name"], queue_url, receipt_handle), + ), + ) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + await incoming.put(exc) + async def listen(self) -> AsyncGenerator[AckableMessage, None]: """ - This function listens to new messages and yields them. + This function listens to new messages on every configured queue and yields them. :yield: incoming AckableMessages. """ - queue_url = await self._get_queue_url() - - while True: - results = await self._sqs_client.receive_message( - QueueUrl=queue_url, - MaxNumberOfMessages=self._max_number_of_messages, - WaitTimeSeconds=self._wait_time_seconds, - ) - messages: list[MessageTypeDef] = results.get("Messages", []) - - for message in messages: - if (body := message.get("Body")) and (receipt_handle := message.get("ReceiptHandle")): - yield AckableMessage( - data=body.encode("utf-8"), - ack=self._build_ack_function(queue_url, receipt_handle), - ) + incoming: asyncio.Queue[_QueueItem] = asyncio.Queue() + pollers = [asyncio.create_task(self._poll_queue(queue, incoming)) for queue in self._queues] + try: + while True: + item = await incoming.get() + if isinstance(item, BaseException): + raise item + yield item + finally: + for task in pollers: + task.cancel() + await asyncio.gather(*pollers, return_exceptions=True) diff --git a/src/taskiq_sqs/exceptions.py b/src/taskiq_sqs/exceptions.py index 12bcc4e..030350b 100644 --- a/src/taskiq_sqs/exceptions.py +++ b/src/taskiq_sqs/exceptions.py @@ -47,3 +47,10 @@ class OffloadedPayloadMissingError(BaseTaskiqSQSError): task_id: str bucket_name: str key: str + + +class UnknownQueueError(BaseTaskiqSQSError): + """Error if a message references a queue that isn't configured on the broker.""" + + __template__ = "Message references queue '{queue_name}' which is not configured on this broker" + queue_name: str diff --git a/src/taskiq_sqs/types/queue.py b/src/taskiq_sqs/types/queue.py index 920b9ee..9cbf3a8 100644 --- a/src/taskiq_sqs/types/queue.py +++ b/src/taskiq_sqs/types/queue.py @@ -1,31 +1,16 @@ -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import Any +from typing import NotRequired, TypedDict -@dataclass(slots=True, kw_only=True, frozen=True) -class SQSQueue: - """Per-queue SQS configuration for SQSBroker. +class SQSQueue(TypedDict): + """ + Represents an SQS queue configuration. Attributes: - name: The SQS queue name (or "queue-name.fifo" for FIFO queues). - is_fifo: Whether this is a FIFO queue (default: False). - max_number_of_messages: Maximum messages to retrieve per poll (1-10, - default: 1). - wait_time_seconds: Long polling wait time in seconds (0-20, default: 0). - visibility_timeout: Optional visibility timeout (in seconds) for received - messages. While a message is being processed, it remains invisible to - other consumers. - options: Optional mapping of additional SQS queue attributes. + name: The SQS queue name. + max_number_of_messages: Maximum messages to retrieve per poll (1-10). Defaults to 1. + wait_time_seconds: Long polling wait time in seconds (0-20). Defaults to 0. """ name: str - max_number_of_messages: int = 1 - wait_time_seconds: int = 0 - options: Mapping[str, Any] = field(default_factory=dict) - - def __str__(self) -> str: # noqa: D105 - return self.name - - def __hash__(self) -> int: # noqa: D105 - return hash(self.name) + max_number_of_messages: NotRequired[int] + wait_time_seconds: NotRequired[int] diff --git a/tests/benchmarks/conftest.py b/tests/benchmarks/conftest.py index 077ab0d..fc9c95a 100644 --- a/tests/benchmarks/conftest.py +++ b/tests/benchmarks/conftest.py @@ -7,6 +7,7 @@ from tests.conftest import AWSCredentials, _queue_name_from_url from taskiq_sqs import S3ResultBackend, SQSBroker +from taskiq_sqs.types import SQSQueue BATCH_SIZE = 10 @@ -31,9 +32,11 @@ async def bench_broker( `ReceiveMessage` call serve the whole batch. """ broker = SQSBroker( - queue_name=_queue_name_from_url(sqs_queue), - wait_time_seconds=1, - max_number_of_messages=BATCH_SIZE, + queues=SQSQueue( + name=_queue_name_from_url(sqs_queue), + wait_time_seconds=1, + max_number_of_messages=BATCH_SIZE, + ), **aws_credentials, ) await broker.startup() diff --git a/tests/benchmarks/test_broker.py b/tests/benchmarks/test_broker.py index 3af5c83..c93b2e6 100644 --- a/tests/benchmarks/test_broker.py +++ b/tests/benchmarks/test_broker.py @@ -10,7 +10,8 @@ @pytest.mark.benchmark async def test_build_kick_kwargs(bench_broker: SQSBroker, broker_message: BrokerMessage) -> None: - await bench_broker._build_kick_kwargs(broker_message) + queue_url = await bench_broker._get_queue_url(bench_broker._default_queue_name) + await bench_broker._build_kick_kwargs(broker_message, queue_url) @pytest.mark.benchmark @@ -37,7 +38,7 @@ async def test_kick_and_listen_roundtrip_batch( ) -> None: for _ in range(BATCH_SIZE): await bench_broker.kick(broker_message) - # SQS may return fewer messages than requested per call, so keep pulling until the whole batch is back. + # SQS may return fewer messages than requested per call, so keep pulling until the whole batch is back. for _ in range(BATCH_SIZE): message = await anext(listener) await ack(message) diff --git a/tests/conftest.py b/tests/conftest.py index e2db27c..d5f4e39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,16 @@ import uuid from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Any, TypedDict +from typing import Any, TypedDict +import capo_s3 +import capo_sqs import pytest -from aiobotocore.session import get_session from taskiq import BrokerMessage -from types_aiobotocore_sqs.client import SQSClient from taskiq_sqs import S3OffloadMiddleware, S3ResultBackend, SQSBroker -from taskiq_sqs.types import S3Bucket +from taskiq_sqs.types import S3Bucket, SQSQueue -if TYPE_CHECKING: - from types_aiobotocore_s3.client import S3Client - ENDPOINT_URL = "http://localhost:4566" TEST_BUCKET = "test-bucket" TEST_OFFLOAD_BUCKET = "test-offload-bucket" @@ -29,7 +26,7 @@ class AWSCredentials(TypedDict): @pytest.fixture(scope="session") def aws_credentials() -> AWSCredentials: - """Mocked AWS Credentials for moto.""" + """Mocked AWS Credentials for ministack.""" return AWSCredentials( endpoint_url=ENDPOINT_URL, aws_access_key_id="your-aws-id", @@ -39,39 +36,38 @@ def aws_credentials() -> AWSCredentials: @pytest.fixture -async def s3_client(aws_credentials: AWSCredentials) -> "AsyncGenerator[S3Client, Any]": - client_context = get_session().create_client( - "s3", - endpoint_url=aws_credentials["endpoint_url"], - aws_access_key_id=aws_credentials["aws_access_key_id"], - aws_secret_access_key=aws_credentials["aws_secret_access_key"], - region_name=aws_credentials["aws_region_name"], +async def s3_client(aws_credentials: AWSCredentials) -> AsyncGenerator[capo_s3.AsyncS3Client, Any]: + """An S3 client independent from the one `S3ResultBackend`/`S3OffloadMiddleware` build internally. + + Used to verify state out of band, so a test doesn't just check that a component agrees with itself. + """ + client = capo_s3.AsyncS3Client( + region=aws_credentials["aws_region_name"], + endpoint=aws_credentials["endpoint_url"], + credentials=capo_s3.Credentials( + access_key=aws_credentials["aws_access_key_id"], + secret_key=aws_credentials["aws_secret_access_key"], + ), + force_path_style=True, ) - yield await client_context.__aenter__() - await client_context.__aexit__(None, None, None) + await client.__aenter__() + yield client + await client.__aexit__(None, None, None) @pytest.fixture -async def s3_bucket(s3_client: S3ResultBackend) -> AsyncGenerator[str, Any]: - response = await s3_client.create_bucket(Bucket=TEST_BUCKET) - assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 - # Ensure the bucket is created - assert "Location" in response - assert response["Location"] == f"/{TEST_BUCKET}" - # Return the bucket name for use in tests +async def s3_bucket(s3_client: capo_s3.AsyncS3Client) -> AsyncGenerator[str, Any]: + await s3_client.create_bucket(bucket=TEST_BUCKET) yield TEST_BUCKET - # Delete all objects in the bucket - response = await s3_client.list_objects_v2(Bucket=TEST_BUCKET) - if "Contents" in response: - objects_to_delete = [{"Key": obj["Key"]} for obj in response.get("Contents", [])] - if objects_to_delete: - await s3_client.delete_objects( - Bucket=TEST_BUCKET, - Delete={"Objects": objects_to_delete}, - ) + await _empty_bucket(s3_client, TEST_BUCKET) + await s3_client.delete_bucket(bucket=TEST_BUCKET) + - # Delete the bucket itself - await s3_client.delete_bucket(Bucket=TEST_BUCKET) +async def _empty_bucket(s3_client: capo_s3.AsyncS3Client, bucket: str) -> None: + response = await s3_client.list_objects_v2(bucket=bucket) + objects = [{"key": obj["key"]} for obj in response.get("contents", []) if "key" in obj] + if objects: + await s3_client.delete_objects(bucket=bucket, delete={"objects": objects}) @pytest.fixture @@ -87,19 +83,11 @@ async def s3_backend( @pytest.fixture -async def s3_offload_bucket(s3_client: S3ResultBackend) -> AsyncGenerator[str, Any]: - response = await s3_client.create_bucket(Bucket=TEST_OFFLOAD_BUCKET) - assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 +async def s3_offload_bucket(s3_client: capo_s3.AsyncS3Client) -> AsyncGenerator[str, Any]: + await s3_client.create_bucket(bucket=TEST_OFFLOAD_BUCKET) yield TEST_OFFLOAD_BUCKET - response = await s3_client.list_objects_v2(Bucket=TEST_OFFLOAD_BUCKET) - if "Contents" in response: - objects_to_delete = [{"Key": obj["Key"]} for obj in response.get("Contents", [])] - if objects_to_delete: - await s3_client.delete_objects( - Bucket=TEST_OFFLOAD_BUCKET, - Delete={"Objects": objects_to_delete}, - ) - await s3_client.delete_bucket(Bucket=TEST_OFFLOAD_BUCKET) + await _empty_bucket(s3_client, TEST_OFFLOAD_BUCKET) + await s3_client.delete_bucket(bucket=TEST_OFFLOAD_BUCKET) @pytest.fixture @@ -118,25 +106,40 @@ async def s3_offload_middleware( @pytest.fixture -async def sqs_client(aws_credentials: AWSCredentials) -> AsyncGenerator[SQSClient, Any]: - client_context = get_session().create_client( - "sqs", - endpoint_url=aws_credentials["endpoint_url"], - aws_access_key_id=aws_credentials["aws_access_key_id"], - aws_secret_access_key=aws_credentials["aws_secret_access_key"], - region_name=aws_credentials["aws_region_name"], +async def sqs_client(aws_credentials: AWSCredentials) -> AsyncGenerator[capo_sqs.AsyncSQSClient, Any]: + """An SQS client independent from the one `SQSBroker` builds internally, for out-of-band verification.""" + client = capo_sqs.AsyncSQSClient( + region=aws_credentials["aws_region_name"], + endpoint=aws_credentials["endpoint_url"], + credentials=capo_sqs.Credentials( + access_key=aws_credentials["aws_access_key_id"], + secret_key=aws_credentials["aws_secret_access_key"], + ), ) - yield await client_context.__aenter__() - await client_context.__aexit__(None, None, None) + await client.__aenter__() + yield client + await client.__aexit__(None, None, None) + + +async def _create_queue(sqs_client: capo_sqs.AsyncSQSClient, name: str) -> str: + response = await sqs_client.create_queue(queue_name=name) + queue_url = response.get("queue_url") + assert queue_url is not None + return queue_url + + +@pytest.fixture +async def sqs_queue(sqs_client: capo_sqs.AsyncSQSClient) -> AsyncGenerator[str, Any]: + queue_url = await _create_queue(sqs_client, f"{QUEUE_NAME}-{uuid.uuid4().hex}") + yield queue_url + await sqs_client.delete_queue(queue_url=queue_url) @pytest.fixture -async def sqs_queue(sqs_client: SQSClient) -> AsyncGenerator[str, Any]: - queue_name = f"{QUEUE_NAME}-{uuid.uuid4().hex}" - response = await sqs_client.create_queue(QueueName=queue_name) - queue_url = response["QueueUrl"] +async def sqs_second_queue(sqs_client: capo_sqs.AsyncSQSClient) -> AsyncGenerator[str, Any]: + queue_url = await _create_queue(sqs_client, f"{QUEUE_NAME}-second-{uuid.uuid4().hex}") yield queue_url - await sqs_client.delete_queue(QueueUrl=queue_url) + await sqs_client.delete_queue(queue_url=queue_url) def _queue_name_from_url(queue_url: str) -> str: @@ -149,12 +152,30 @@ async def sqs_broker( sqs_queue: str, ) -> AsyncGenerator[SQSBroker, Any]: broker = SQSBroker( - queue_name=_queue_name_from_url(sqs_queue), + queues=SQSQueue(name=_queue_name_from_url(sqs_queue)), **aws_credentials, ) await broker.startup() assert broker._sqs_client - assert broker._sqs_queue_url + assert broker._queue_urls + yield broker + await broker.shutdown() + + +@pytest.fixture +async def multiqueue_sqs_broker( + aws_credentials: AWSCredentials, + sqs_queue: str, + sqs_second_queue: str, +) -> AsyncGenerator[SQSBroker, Any]: + broker = SQSBroker( + queues=[ + SQSQueue(name=_queue_name_from_url(sqs_queue)), + SQSQueue(name=_queue_name_from_url(sqs_second_queue)), + ], + **aws_credentials, + ) + await broker.startup() yield broker await broker.shutdown() diff --git a/tests/test_broker_initialization.py b/tests/test_broker_initialization.py index 07d8d5b..0ce08da 100644 --- a/tests/test_broker_initialization.py +++ b/tests/test_broker_initialization.py @@ -4,20 +4,56 @@ from taskiq_sqs import SQSBroker from taskiq_sqs.exceptions import BrokerInitError +from taskiq_sqs.types import SQSQueue -@pytest.mark.asyncio async def test_get_queue_url_client_error(aws_credentials: AWSCredentials) -> None: - broker = SQSBroker(queue_name="nonexistent-queue", **aws_credentials) + broker = SQSBroker(queues=SQSQueue(name="nonexistent-queue"), **aws_credentials) with pytest.raises(BrokerInitError): await broker.startup() -@pytest.mark.asyncio async def test_max_number_of_messages_error(aws_credentials: AWSCredentials) -> None: with pytest.raises(BrokerInitError): SQSBroker( - queue_name="nonexistent-queue", - max_number_of_messages=15, + queues=SQSQueue(name="nonexistent-queue", max_number_of_messages=15), **aws_credentials, ) + + +async def test_wait_time_seconds_error(aws_credentials: AWSCredentials) -> None: + with pytest.raises(BrokerInitError): + SQSBroker( + queues=SQSQueue(name="nonexistent-queue", wait_time_seconds=21), + **aws_credentials, + ) + + +async def test_when_no_queues_given__then_should_raise_an_error(aws_credentials: AWSCredentials) -> None: + with pytest.raises(BrokerInitError): + SQSBroker(queues=[], **aws_credentials) + + +async def test_when_duplicate_queue_names__then_should_raise_an_error(aws_credentials: AWSCredentials) -> None: + with pytest.raises(BrokerInitError): + SQSBroker( + queues=[SQSQueue(name="same-name"), SQSQueue(name="same-name")], + **aws_credentials, + ) + + +async def test_when_single_queue_dict_given__then_it_becomes_the_default_queue( + aws_credentials: AWSCredentials, +) -> None: + broker = SQSBroker(queues=SQSQueue(name="my-queue"), **aws_credentials) + assert broker._default_queue_name == "my-queue" + + +async def test_when_multiple_queues_given__then_the_first_one_is_the_default( + aws_credentials: AWSCredentials, +) -> None: + broker = SQSBroker( + queues=[SQSQueue(name="first-queue"), SQSQueue(name="second-queue")], + **aws_credentials, + ) + assert broker._default_queue_name == "first-queue" diff --git a/tests/test_broker_kick.py b/tests/test_broker_kick.py index d13079a..18dc790 100644 --- a/tests/test_broker_kick.py +++ b/tests/test_broker_kick.py @@ -1,26 +1,68 @@ +import capo_sqs import pytest from taskiq import BrokerMessage +from tests.conftest import _queue_name_from_url + from taskiq_sqs import SQSBroker -from taskiq_sqs.exceptions import BrokerInitError +from taskiq_sqs.broker import SQS_QUEUE_LABEL +from taskiq_sqs.exceptions import BrokerInitError, UnknownQueueError async def test_when_kick_called__than_message_should_be_published_to_queue( sqs_broker: SQSBroker, + sqs_client: capo_sqs.AsyncSQSClient, sqs_queue: str, broker_message: BrokerMessage, ) -> None: await sqs_broker.kick(broker_message) - response = await sqs_broker._sqs_client.receive_message(QueueUrl=sqs_queue) - assert "Messages" in response - assert len(response["Messages"]) == 1 - assert response["Messages"][0]["Body"] == "test_message" + + response = await sqs_client.receive_message(queue_url=sqs_queue) + messages = response.get("messages", []) + assert len(messages) == 1 + assert messages[0].get("body") == "test_message" async def test_when_during_kick_queue_not_found__then_should_raise_an_error( sqs_broker: SQSBroker, broker_message: BrokerMessage, ) -> None: - sqs_broker._sqs_queue_url = "nonexistent-queue" + sqs_broker._queue_urls[sqs_broker._default_queue_name] = "nonexistent-queue" with pytest.raises(BrokerInitError): await sqs_broker.kick(broker_message) + + +async def test_when_kick_called_without_queue_label__then_message_goes_to_default_queue( + multiqueue_sqs_broker: SQSBroker, + sqs_client: capo_sqs.AsyncSQSClient, + sqs_queue: str, + broker_message: BrokerMessage, +) -> None: + await multiqueue_sqs_broker.kick(broker_message) + + response = await sqs_client.receive_message(queue_url=sqs_queue) + assert len(response.get("messages", [])) == 1 + + +async def test_when_kick_called_with_queue_label__then_message_goes_to_that_queue( + multiqueue_sqs_broker: SQSBroker, + sqs_client: capo_sqs.AsyncSQSClient, + sqs_second_queue: str, + broker_message: BrokerMessage, +) -> None: + broker_message.labels[SQS_QUEUE_LABEL] = _queue_name_from_url(sqs_second_queue) + + await multiqueue_sqs_broker.kick(broker_message) + + response = await sqs_client.receive_message(queue_url=sqs_second_queue) + assert len(response.get("messages", [])) == 1 + + +async def test_when_kick_called_with_unknown_queue_label__then_should_raise_an_error( + multiqueue_sqs_broker: SQSBroker, + broker_message: BrokerMessage, +) -> None: + broker_message.labels[SQS_QUEUE_LABEL] = "unknown-queue" + + with pytest.raises(UnknownQueueError): + await multiqueue_sqs_broker.kick(broker_message) diff --git a/tests/test_broker_listen.py b/tests/test_broker_listen.py index f6b9e17..ed761b4 100644 --- a/tests/test_broker_listen.py +++ b/tests/test_broker_listen.py @@ -1,22 +1,50 @@ +import capo_sqs + from taskiq_sqs import SQSBroker async def test_when_listen__than_we_should_delete_message_from_queue( - sqs_broker: SQSBroker, sqs_queue: str, + sqs_broker: SQSBroker, + sqs_client: capo_sqs.AsyncSQSClient, + sqs_queue: str, ) -> None: - await sqs_broker._sqs_client.send_message( - QueueUrl=sqs_queue, - MessageBody="test_message", - ) + await sqs_client.send_message(queue_url=sqs_queue, message_body="test_message") messages = [] - async for message in sqs_broker.listen(): - messages.append(message) - await message.ack() - break + generator = sqs_broker.listen() + try: + async for message in generator: + messages.append(message) + await message.ack() + break + finally: + await generator.aclose() assert len(messages) == 1 assert messages[0].data == b"test_message" - response = await sqs_broker._sqs_client.receive_message(QueueUrl=sqs_queue) - assert "Messages" not in response + response = await sqs_client.receive_message(queue_url=sqs_queue) + assert not response.get("messages") + + +async def test_when_listen_with_multiple_queues__then_messages_from_both_are_received( + multiqueue_sqs_broker: SQSBroker, + sqs_client: capo_sqs.AsyncSQSClient, + sqs_queue: str, + sqs_second_queue: str, +) -> None: + await sqs_client.send_message(queue_url=sqs_queue, message_body="from_first_queue") + await sqs_client.send_message(queue_url=sqs_second_queue, message_body="from_second_queue") + + messages = [] + generator = multiqueue_sqs_broker.listen() + try: + async for message in generator: + messages.append(message) + await message.ack() + if len(messages) == 2: + break + finally: + await generator.aclose() + + assert {message.data for message in messages} == {b"from_first_queue", b"from_second_queue"} diff --git a/tests/test_result_backend.py b/tests/test_result_backend.py index c77924d..f4c30aa 100644 --- a/tests/test_result_backend.py +++ b/tests/test_result_backend.py @@ -1,7 +1,8 @@ import uuid from collections.abc import AsyncGenerator -from typing import TYPE_CHECKING, Any +from typing import Any +import capo_s3 import pytest from taskiq.result import TaskiqResult @@ -12,10 +13,6 @@ from taskiq_sqs.types import S3Bucket -if TYPE_CHECKING: - from types_aiobotocore_s3.client import S3Client - - @pytest.fixture def taskiq_result() -> TaskiqResult: return TaskiqResult(return_value="test_value", is_err=True, execution_time=0.1) @@ -25,17 +22,14 @@ class TestResultBackend: async def test_when_result_set__then_result_is_actually_saved_to_s3( self, s3_backend: S3ResultBackend, - s3_client: "S3Client", + s3_client: capo_s3.AsyncS3Client, s3_bucket: str, taskiq_result: TaskiqResult, ) -> None: await s3_backend.set_result("test_task_id", taskiq_result) - response = await s3_client.get_object( - Bucket=s3_bucket, - Key="test_task_id", - ) - assert response["Body"] is not None + async with s3_client.get_object(bucket=s3_bucket, key="test_task_id") as response: + assert response["body"] is not None async def test_when_result_present_in_s3__then_get_result_return_it( self, @@ -51,17 +45,14 @@ async def test_when_result_present_in_s3__then_get_result_return_it( async def test_when_set_result_is_called__then_save_it_to_right_path( self, s3_backend: S3ResultBackend, - s3_client: "S3Client", + s3_client: capo_s3.AsyncS3Client, s3_bucket: str, taskiq_result: TaskiqResult, ) -> None: s3_backend._base_path = "results" await s3_backend.set_result("test_task_id", taskiq_result) - response = await s3_client.head_object( - Bucket=s3_bucket, - Key="results/test_task_id", - ) + response = await s3_client.head_object(bucket=s3_bucket, key="results/test_task_id") assert response is not None async def test_when_result_is_set__then_we_should_be_able_to_get_it( @@ -128,12 +119,12 @@ class TestBucketDeclare: backend: S3ResultBackend | None @staticmethod - async def _bucket_exists(s3_client: "S3Client", name: str) -> bool: + async def _bucket_exists(s3_client: capo_s3.AsyncS3Client, name: str) -> bool: response = await s3_client.list_buckets() - return any(bucket["Name"] == name for bucket in response.get("Buckets", [])) + return any(bucket.get("name") == name for bucket in response.get("buckets", [])) @pytest.fixture(autouse=True) - async def _setup(self, s3_client: "S3Client") -> AsyncGenerator[None, Any]: + async def _setup(self, s3_client: capo_s3.AsyncS3Client) -> AsyncGenerator[None, Any]: self.tmp_bucket_name = f"declare-test-{uuid.uuid4().hex[:8]}" self.backend = None yield @@ -141,16 +132,16 @@ async def _setup(self, s3_client: "S3Client") -> AsyncGenerator[None, Any]: await self.backend.shutdown() if not await self._bucket_exists(s3_client, self.tmp_bucket_name): return - response = await s3_client.list_objects_v2(Bucket=self.tmp_bucket_name) - objects = [{"Key": obj["Key"]} for obj in response.get("Contents", [])] + response = await s3_client.list_objects_v2(bucket=self.tmp_bucket_name) + objects = [{"key": obj["key"]} for obj in response.get("contents", []) if "key" in obj] if objects: - await s3_client.delete_objects(Bucket=self.tmp_bucket_name, Delete={"Objects": objects}) - await s3_client.delete_bucket(Bucket=self.tmp_bucket_name) + await s3_client.delete_objects(bucket=self.tmp_bucket_name, delete={"objects": objects}) + await s3_client.delete_bucket(bucket=self.tmp_bucket_name) async def test_when_declare_true_and_bucket_missing__then_bucket_is_created_on_startup( self, aws_credentials: AWSCredentials, - s3_client: "S3Client", + s3_client: capo_s3.AsyncS3Client, ) -> None: self.backend = S3ResultBackend( bucket=S3Bucket(name=self.tmp_bucket_name, declare=True), @@ -163,7 +154,7 @@ async def test_when_declare_true_and_bucket_missing__then_bucket_is_created_on_s async def test_when_declare_false_and_bucket_missing__then_startup_raises( self, aws_credentials: AWSCredentials, - s3_client: "S3Client", + s3_client: capo_s3.AsyncS3Client, ) -> None: backend = S3ResultBackend( bucket=S3Bucket(name=self.tmp_bucket_name, declare=False), @@ -178,7 +169,7 @@ async def test_when_declare_false_and_bucket_missing__then_startup_raises( async def test_when_declare_false_and_bucket_exists__then_startup_succeeds( self, aws_credentials: AWSCredentials, - s3_client: "S3Client", + s3_client: capo_s3.AsyncS3Client, s3_bucket: str, ) -> None: self.backend = S3ResultBackend( @@ -192,7 +183,7 @@ async def test_when_declare_false_and_bucket_exists__then_startup_succeeds( async def test_when_declare_true_and_bucket_already_exists__then_startup_is_idempotent( self, aws_credentials: AWSCredentials, - s3_client: "S3Client", + s3_client: capo_s3.AsyncS3Client, s3_bucket: str, ) -> None: self.backend = S3ResultBackend( diff --git a/uv.lock b/uv.lock index d20fdca..8115248 100644 --- a/uv.lock +++ b/uv.lock @@ -6,21 +6,6 @@ resolution-markers = [ "python_full_version < '3.15'", ] -[[package]] -name = "aiobotocore" -version = "2.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "aioitertools" }, - { name = "botocore" }, - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ea/17/2f6305cc52976dea8156b56badc3602f162f86693a6cc8badc20d2c5cfe6/aiobotocore-2.13.3.tar.gz", hash = "sha256:ac5620f93cc3e7c2aef7c67ba2bb74035ff8d49ee2325821daed13b3dd82a473", size = 106736, upload-time = "2024-08-22T20:42:13.086Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/98/1c1a1ba5d74f87cbabbf207d36a98551b082b8b187dc35f7808b43f4cf1a/aiobotocore-2.13.3-py3-none-any.whl", hash = "sha256:1272f765fd9414e1a68f8add71978367db94e17e36c3bf629cf1153eb5141fb9", size = 77194, upload-time = "2024-08-22T20:42:09.784Z" }, -] - [[package]] name = "aiohappyeyeballs" version = "2.6.2" @@ -132,15 +117,6 @@ wheels = [ { 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" }, ] -[[package]] -name = "aioitertools" -version = "0.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } -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 = "aiosignal" version = "1.4.0" @@ -249,32 +225,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -[[package]] -name = "botocore" -version = "1.34.162" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jmespath" }, - { name = "python-dateutil" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/de/17d672eac6725da49bd5832e3bd2f74c4d212311cd393fd56b59f51a4e86/botocore-1.34.162.tar.gz", hash = "sha256:adc23be4fb99ad31961236342b7cbf3c0bfc62532cd02852196032e8c0d682f3", size = 12676693, upload-time = "2024-08-15T19:25:25.162Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/47/e35f788047c91110f48703a6254e5c84e33111b3291f7b57a653ca00accf/botocore-1.34.162-py3-none-any.whl", hash = "sha256:2d918b02db88d27a75b48275e6fb2506e9adaaddbec1ffa6a8a0898b34e769be", size = 12468049, upload-time = "2024-08-15T19:25:18.301Z" }, -] - -[[package]] -name = "botocore-stubs" -version = "1.43.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "types-awscrt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7f/81/79693e833291c00dc89ee610e5e915381b6f08233912e28df50106840780/botocore_stubs-1.43.14.tar.gz", hash = "sha256:9e3bc1fdd51da7473f0df726c82747a1b0ae913449d629659765c247fecc2039", size = 42738, upload-time = "2026-05-25T06:06:37.484Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/ca/f017727b11895908c5dedc829cf2ec35e0c4b2a26ba875db325fef2cefdf/botocore_stubs-1.43.14-py3-none-any.whl", hash = "sha256:fb98f1475c92fd718644e786b5c543a20f1b1f610e89e0a7191c3f1f429c75aa", size = 67093, upload-time = "2026-05-25T06:06:34.532Z" }, -] - [[package]] name = "capo-s3" version = "0.15.0" @@ -471,15 +421,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jmespath" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, -] - [[package]] name = "librt" version = "0.15.0" @@ -1251,18 +1192,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/b2/1d2a993c532146dce9eca5b5942d51898021c3579ce18b2454f932a915f8/pytest_codspeed-5.0.3-py3-none-any.whl", hash = "sha256:fe2ea83c924c2250675b75686c3ee456b8cf0208d83d552e182a195fdf467378", size = 74033, upload-time = "2026-05-22T16:20:26.814Z" }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.3" @@ -1377,15 +1306,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - [[package]] name = "taskiq" version = "0.12.6" @@ -1417,7 +1337,6 @@ name = "taskiq-sqs" version = "0.0.11" source = { editable = "." } dependencies = [ - { name = "aiobotocore" }, { name = "capo-s3" }, { name = "capo-sqs" }, { name = "taskiq" }, @@ -1432,7 +1351,6 @@ dev = [ { name = "pytest-codspeed" }, { name = "python-dotenv" }, { name = "ruff" }, - { name = "types-aiobotocore", extra = ["essential"] }, { name = "zensical" }, { name = "zizmor" }, ] @@ -1445,7 +1363,6 @@ examples = [ lint = [ { name = "mypy" }, { name = "ruff" }, - { name = "types-aiobotocore", extra = ["essential"] }, { name = "zizmor" }, ] test = [ @@ -1456,7 +1373,6 @@ test = [ [package.metadata] requires-dist = [ - { name = "aiobotocore", specifier = ">=2.13.3" }, { name = "capo-s3", specifier = ">=0.15.0" }, { name = "capo-sqs", specifier = ">=0.6.0" }, { name = "taskiq", specifier = ">=0.12.6" }, @@ -1471,7 +1387,6 @@ dev = [ { name = "pytest-codspeed", specifier = ">=5.0.3" }, { name = "python-dotenv", specifier = ">=1.2.3" }, { name = "ruff", specifier = ">=0.16.7" }, - { name = "types-aiobotocore", extras = ["essential"], specifier = ">=3.7.0" }, { name = "zensical", specifier = ">=0.0.62" }, { name = "zizmor", specifier = ">=1.30.1" }, ] @@ -1480,7 +1395,6 @@ examples = [{ name = "python-dotenv", specifier = ">=1.2.3" }] lint = [ { name = "mypy", specifier = ">=2.3.1" }, { name = "ruff", specifier = ">=0.16.7" }, - { name = "types-aiobotocore", extras = ["essential"], specifier = ">=3.7.0" }, { name = "zizmor", specifier = ">=1.30.1" }, ] test = [ @@ -1543,123 +1457,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] -[[package]] -name = "types-aiobotocore" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "botocore-stubs" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/e8/ef1fcb876937dbdddc0f01b5df4ed53f33b166a6367d80a9014d5e5f091d/types_aiobotocore-3.7.0.tar.gz", hash = "sha256:fe35de52c12e5fdb89ca60b3989766e7fe827e3d2e95fcf4583e91581945205c", size = 87992, upload-time = "2026-05-10T03:19:32.353Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/68/0cdfd7df415ee3e769c8e8f9bd8013c64c88cdd7306f72453a02123c58f9/types_aiobotocore-3.7.0-py3-none-any.whl", hash = "sha256:ff4139b3eae22d242b6b39ba56048344b2b86f67daeeca4680da1a6e191681fd", size = 54804, upload-time = "2026-05-10T03:19:29.487Z" }, -] - -[package.optional-dependencies] -essential = [ - { name = "types-aiobotocore-cloudformation" }, - { name = "types-aiobotocore-dynamodb" }, - { name = "types-aiobotocore-ec2" }, - { name = "types-aiobotocore-lambda" }, - { name = "types-aiobotocore-rds" }, - { name = "types-aiobotocore-s3" }, - { name = "types-aiobotocore-sqs" }, -] - -[[package]] -name = "types-aiobotocore-cloudformation" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/80/611e1f376daebfd852ece2bf2affe1248ca80b4e4e3c928e80b55657935c/types_aiobotocore_cloudformation-3.7.0.tar.gz", hash = "sha256:8dd927a11181dac6b1bc009fb897ded56b2bb4645a83a617dc0afdf677f92315", size = 61470, upload-time = "2026-05-10T03:11:28.753Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/08/24734916c2dd3965fae0a205bbc44d367643eb9b6b889ab9c6e40e854511/types_aiobotocore_cloudformation-3.7.0-py3-none-any.whl", hash = "sha256:c6abb3089b37a7b706b10f488e06fd7e856140364d0ab7cd07071579b4c50932", size = 73097, upload-time = "2026-05-10T03:11:26.695Z" }, -] - -[[package]] -name = "types-aiobotocore-dynamodb" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/3c/05a94ed5d7a0a626824bd84be7646522529601513e288f07c2ea15acaffe/types_aiobotocore_dynamodb-3.7.0.tar.gz", hash = "sha256:bb23c9923734ed8d461da0b8a1166a0b8d9080e63c2e65e32cd03de57080555d", size = 49044, upload-time = "2026-05-10T03:12:54.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/c8/491c357e0d52d2fdf5842c6ffc9cf698d4e127fa11e33ff3c7b389ef86a5/types_aiobotocore_dynamodb-3.7.0-py3-none-any.whl", hash = "sha256:4624542dd2863d0872b2b51b5823272988eac084918e4fc17db899c2910d133c", size = 59873, upload-time = "2026-05-10T03:12:51.494Z" }, -] - -[[package]] -name = "types-aiobotocore-ec2" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/f3/b9596a9c80afb7b0b2444aaef2e03578658c3db75a07b07ebfac51b8f942/types_aiobotocore_ec2-3.7.0.tar.gz", hash = "sha256:b2bf88aa7fef5b68f15c2630dea5e2ecef6da8e68f762b80300064af8e4a90fb", size = 447517, upload-time = "2026-05-10T03:12:58.441Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/d3/0405109c5b680d6c79e52b302a28ddbf20b4fcb963922568f31a69ea9827/types_aiobotocore_ec2-3.7.0-py3-none-any.whl", hash = "sha256:39edb4aa72be6c85c33b228ef55be5630a24d0f0c45dfe6056082c5f4daf701d", size = 436317, upload-time = "2026-05-10T03:12:56.547Z" }, -] - -[[package]] -name = "types-aiobotocore-lambda" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/8c/edc3a4eeb619dd389b6d6200f0cbe8a9ef3389584ad4da6fedd77fac8631/types_aiobotocore_lambda-3.7.0.tar.gz", hash = "sha256:2937cb841179d38e6dac812e1dc8bbaa3aaf35574984029a1dcac8bff4f8b31c", size = 51962, upload-time = "2026-05-10T03:14:55.508Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/80/bdd66265fe3851c0943b03e4f26481a7fe1a0d8cc5cd58d2febc14061304/types_aiobotocore_lambda-3.7.0-py3-none-any.whl", hash = "sha256:650b71b4e0f6ed7c9549baa115db55450f5b55664e208aa6934daea79f230c39", size = 60851, upload-time = "2026-05-10T03:14:53.599Z" }, -] - -[[package]] -name = "types-aiobotocore-rds" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6a/8a/0f90cda2b6ae6a7f425eb6b1e7d29c0ebbc07c7a5c5b9f50b4128013df5b/types_aiobotocore_rds-3.7.0.tar.gz", hash = "sha256:3757ffd24d48941ce0ed46c5fd7badcba0ef24b9cf7543b186a0d86a554ec49d", size = 87363, upload-time = "2026-05-10T03:17:05.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/93/9fe55245ecf52743af3d3e68c43e0b6c1f14572db8fba8e73d041f117026/types_aiobotocore_rds-3.7.0-py3-none-any.whl", hash = "sha256:b503239f4b4a5f2a8b3366b338e30733624d932beb8520c84ad3e197a774dc7d", size = 94818, upload-time = "2026-05-10T03:17:03.379Z" }, -] - -[[package]] -name = "types-aiobotocore-s3" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/ce/5b7baa06cde79ab5f083f56f060ccd2b60f6249127d70bbb8a37aafbdcd4/types_aiobotocore_s3-3.7.0.tar.gz", hash = "sha256:6ec738853dbba9133707991b98ea4dab19f7c62e02b3cca016e6cd8e3d684576", size = 77662, upload-time = "2026-05-10T03:17:35.088Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/ac/67240c3804a3e1c1e8f0f402155d7ed5351faf7a2085136083e5ca3f26b8/types_aiobotocore_s3-3.7.0-py3-none-any.whl", hash = "sha256:5dbb5479ece3e1aceaccac224fc3dd1ab7d912c4457419bd19e88a0a5b3844fc", size = 85450, upload-time = "2026-05-10T03:17:33.061Z" }, -] - -[[package]] -name = "types-aiobotocore-sqs" -version = "3.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/15/e1/d207fd95f8b45d53488a8581e4f1e4641f9d7fe7fa477d5737ac70833820/types_aiobotocore_sqs-3.7.0.tar.gz", hash = "sha256:52000164f92ab6ec18fd2f6a9989997a2690d1b1c4a851d3c5796a5286d17d00", size = 23423, upload-time = "2026-05-10T03:18:27.162Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/6b/eef2901bd3bf25209a8182f83969a02729cbd2f2f93f0ec47e24dc74d475/types_aiobotocore_sqs-3.7.0-py3-none-any.whl", hash = "sha256:57f263c4d00de1a165a3863e498fd0837fb75a332f8423b30916d4071a3e79fd", size = 34273, upload-time = "2026-05-10T03:18:25.66Z" }, -] - -[[package]] -name = "types-awscrt" -version = "0.33.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/e3/40e8148a07a3c1c6fb151eddcfdd94083560e759e954b1f5a67ca0b36bcf/types_awscrt-0.33.0.tar.gz", hash = "sha256:803bc7e7e2f6172a0abd71df6593368f82fc23127ca15d287f360e9fcbd3a977", size = 19038, upload-time = "2026-05-25T06:56:04.027Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/a6/704fbf052edf2497d09292e5f9555b400594924f1154d6c86f2173c2fc11/types_awscrt-0.33.0-py3-none-any.whl", hash = "sha256:95adb57388e1cacc6e7e96fb7ddc735e60096a6151930640bdbe496d9400493a", size = 45687, upload-time = "2026-05-25T06:56:02.549Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -1681,74 +1478,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "urllib3" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, -] - -[[package]] -name = "wrapt" -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/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, - { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, - { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, - { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, - { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, - { 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]] name = "yarl" version = "1.24.2"