From e0dfdaa739c4d5fac3ff163b0ba08783fe47e1f4 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Fri, 5 Jun 2026 19:12:04 +0300 Subject: [PATCH 01/19] feat(sqs): add AWS SQS broker support Implement the SQS broker for FastStream, providing support for standard and FIFO queues, manual acknowledgement, RPC (request/response), and integration with FastAPI. - Add `SQSBroker` and `SQSRouter` - Implement `SQSQueue` and `FifoQueue` schemas for queue declaration - Support SQS-specific features: FIFO, redrive policies, and visibility timeouts - Add `SQSMessage` with `ack`, `nack`, and `reject` methods - Implement RPC pattern using a dedicated response queue - Add OpenTelemetry and Prometheus middleware support - Include comprehensive test suite with `TestSQSBroker` in-memory double - Add documentation for SQS usage and configuration --- docs/docs/en/sqs/ack.md | 48 +++ docs/docs/en/sqs/fifo.md | 39 ++ docs/docs/en/sqs/index.md | 84 ++++ docs/docs/en/sqs/publishing.md | 50 +++ docs/docs/en/sqs/rpc.md | 33 ++ docs/docs/en/sqs/security.md | 36 ++ docs/docs/en/sqs/subscription.md | 45 ++ docs/docs/navigation_template.txt | 7 + .../asgi/factories/asyncapi/try_it_out.py | 5 + faststream/exceptions.py | 5 + faststream/sqs/__init__.py | 34 ++ faststream/sqs/annotations.py | 18 + faststream/sqs/broker/__init__.py | 0 faststream/sqs/broker/broker.py | 313 ++++++++++++++ faststream/sqs/broker/config.py | 59 +++ faststream/sqs/broker/logging.py | 56 +++ faststream/sqs/broker/registrator.py | 156 +++++++ faststream/sqs/broker/router.py | 124 ++++++ faststream/sqs/fastapi/__init__.py | 19 + faststream/sqs/fastapi/fastapi.py | 234 ++++++++++ faststream/sqs/message.py | 55 +++ faststream/sqs/opentelemetry/__init__.py | 3 + faststream/sqs/opentelemetry/middleware.py | 24 ++ faststream/sqs/opentelemetry/provider.py | 54 +++ faststream/sqs/parser.py | 70 +++ faststream/sqs/prometheus/__init__.py | 3 + faststream/sqs/prometheus/middleware.py | 33 ++ faststream/sqs/prometheus/provider.py | 33 ++ faststream/sqs/publisher/__init__.py | 0 faststream/sqs/publisher/config.py | 23 + faststream/sqs/publisher/factory.py | 49 +++ faststream/sqs/publisher/fake.py | 29 ++ faststream/sqs/publisher/producer.py | 182 ++++++++ faststream/sqs/publisher/specification.py | 48 +++ faststream/sqs/publisher/usecase.py | 105 +++++ faststream/sqs/response.py | 162 +++++++ faststream/sqs/schemas/__init__.py | 13 + faststream/sqs/schemas/queue.py | 105 +++++ faststream/sqs/security.py | 27 ++ faststream/sqs/subscriber/__init__.py | 0 faststream/sqs/subscriber/config.py | 37 ++ faststream/sqs/subscriber/factory.py | 59 +++ faststream/sqs/subscriber/specification.py | 61 +++ faststream/sqs/subscriber/usecase.py | 184 ++++++++ faststream/sqs/testing.py | 209 +++++++++ pyproject.toml | 8 +- tests/brokers/sqs/__init__.py | 0 tests/brokers/sqs/basic.py | 23 + tests/brokers/sqs/conftest.py | 25 ++ tests/brokers/sqs/test_connect.py | 64 +++ tests/brokers/sqs/test_testclient.py | 97 +++++ tests/marks.py | 13 + uv.lock | 399 +++++++++++++++++- 53 files changed, 3556 insertions(+), 6 deletions(-) create mode 100644 docs/docs/en/sqs/ack.md create mode 100644 docs/docs/en/sqs/fifo.md create mode 100644 docs/docs/en/sqs/index.md create mode 100644 docs/docs/en/sqs/publishing.md create mode 100644 docs/docs/en/sqs/rpc.md create mode 100644 docs/docs/en/sqs/security.md create mode 100644 docs/docs/en/sqs/subscription.md create mode 100644 faststream/sqs/__init__.py create mode 100644 faststream/sqs/annotations.py create mode 100644 faststream/sqs/broker/__init__.py create mode 100644 faststream/sqs/broker/broker.py create mode 100644 faststream/sqs/broker/config.py create mode 100644 faststream/sqs/broker/logging.py create mode 100644 faststream/sqs/broker/registrator.py create mode 100644 faststream/sqs/broker/router.py create mode 100644 faststream/sqs/fastapi/__init__.py create mode 100644 faststream/sqs/fastapi/fastapi.py create mode 100644 faststream/sqs/message.py create mode 100644 faststream/sqs/opentelemetry/__init__.py create mode 100644 faststream/sqs/opentelemetry/middleware.py create mode 100644 faststream/sqs/opentelemetry/provider.py create mode 100644 faststream/sqs/parser.py create mode 100644 faststream/sqs/prometheus/__init__.py create mode 100644 faststream/sqs/prometheus/middleware.py create mode 100644 faststream/sqs/prometheus/provider.py create mode 100644 faststream/sqs/publisher/__init__.py create mode 100644 faststream/sqs/publisher/config.py create mode 100644 faststream/sqs/publisher/factory.py create mode 100644 faststream/sqs/publisher/fake.py create mode 100644 faststream/sqs/publisher/producer.py create mode 100644 faststream/sqs/publisher/specification.py create mode 100644 faststream/sqs/publisher/usecase.py create mode 100644 faststream/sqs/response.py create mode 100644 faststream/sqs/schemas/__init__.py create mode 100644 faststream/sqs/schemas/queue.py create mode 100644 faststream/sqs/security.py create mode 100644 faststream/sqs/subscriber/__init__.py create mode 100644 faststream/sqs/subscriber/config.py create mode 100644 faststream/sqs/subscriber/factory.py create mode 100644 faststream/sqs/subscriber/specification.py create mode 100644 faststream/sqs/subscriber/usecase.py create mode 100644 faststream/sqs/testing.py create mode 100644 tests/brokers/sqs/__init__.py create mode 100644 tests/brokers/sqs/basic.py create mode 100644 tests/brokers/sqs/conftest.py create mode 100644 tests/brokers/sqs/test_connect.py create mode 100644 tests/brokers/sqs/test_testclient.py diff --git a/docs/docs/en/sqs/ack.md b/docs/docs/en/sqs/ack.md new file mode 100644 index 00000000000..c032f41ee61 --- /dev/null +++ b/docs/docs/en/sqs/ack.md @@ -0,0 +1,48 @@ +# Acknowledgement + +SQS acknowledgement maps onto the queue API: + +| FastStream | SQS action | Effect | +|------------|-----------|--------| +| `ack` | `DeleteMessage` | Message handled — removed from the queue. | +| `nack` | `ChangeMessageVisibility(0)` | Returned immediately for redelivery. | +| `reject` | `DeleteMessage` | Dropped (route to a DLQ via a redrive policy). | + +The default [`AckPolicy`](../getting-started/acknowledgement.md) for SQS +subscribers is `ACK` (the message is deleted after the handler succeeds, giving +at-least-once delivery). On error the message is **not** deleted and SQS +redelivers it once its visibility timeout expires. + +```python linenums="1" +from faststream import AckPolicy + +broker = SQSBroker(region_name="us-east-1", ack_policy=AckPolicy.NACK_ON_ERROR) +``` + +## Manual acknowledgement + +```python linenums="1" +from faststream.sqs.annotations import SQSMessage + + +@broker.subscriber("my-queue", ack_policy=AckPolicy.MANUAL) +async def handler(body: str, msg: SQSMessage) -> None: + await msg.ack() # DeleteMessage + # or: await msg.nack() / await msg.reject() +``` + +## Dead-letter queues + +Route exhausted messages to a DLQ with a redrive policy on the queue: + +```python linenums="1" +from faststream.sqs import RedrivePolicy, SQSQueue + +queue = SQSQueue( + name="orders", + redrive_policy=RedrivePolicy( + dead_letter_target_arn="arn:aws:sqs:us-east-1:000000000000:orders-dlq", + max_receive_count=5, + ), +) +``` diff --git a/docs/docs/en/sqs/fifo.md b/docs/docs/en/sqs/fifo.md new file mode 100644 index 00000000000..5610391d2e4 --- /dev/null +++ b/docs/docs/en/sqs/fifo.md @@ -0,0 +1,39 @@ +# FIFO queues + +FIFO queues guarantee ordering and exactly-once processing within a message +group. Declare one with `FifoQueue` — the `.fifo` suffix is added automatically: + +```python linenums="1" +from faststream.sqs import FifoQueue, SQSBroker + +broker = SQSBroker(region_name="us-east-1") + +orders = FifoQueue( + name="orders", + content_based_deduplication=True, + deduplication_scope="messageGroup", + fifo_throughput_limit="perMessageGroupId", +) + + +@broker.subscriber(orders) +async def handler(msg: str) -> None: + ... +``` + +## Publishing to FIFO queues + +FIFO sends require a `MessageGroupId`. When content-based deduplication is off, +also provide a `deduplication_id`: + +```python linenums="1" +await broker.publish( + "ordered-event", + orders, + group_id="customer-42", + deduplication_id="evt-1001", +) +``` + +Messages sharing a `group_id` are delivered in strict order; different groups +are processed in parallel. diff --git a/docs/docs/en/sqs/index.md b/docs/docs/en/sqs/index.md new file mode 100644 index 00000000000..5fde45a385e --- /dev/null +++ b/docs/docs/en/sqs/index.md @@ -0,0 +1,84 @@ +--- +# 0.5 - API +# 2 - Release +# 3 - Contributing +# 5 - Template Page +# 10 - Default +search: + boost: 10 +--- + +# AWS SQS routing + +!!! note "" + **FastStream** SQS support is implemented on top of [**aiobotocore**](https://pypi.org/project/aiobotocore/){.external-link target="_blank"} — an `asyncio` wrapper around `botocore`. You can reach the underlying `aiobotocore` SQS client through `broker.config.client` when you need APIs not wrapped by FastStream. + +## Why SQS + +[Amazon SQS](https://aws.amazon.com/sqs/){.external-link target="_blank"} is a fully managed message queue. Unlike push-based brokers, SQS is **pull-based**: consumers long-poll a queue for messages, process them, and explicitly delete them when done. SQS offers **standard** (at-least-once, best-effort ordering) and **FIFO** (exactly-once processing, strict ordering) queues. + +## FastStream `SQSBroker` + +```python linenums="1" +from faststream import FastStream +from faststream.sqs import SQSBroker + +broker = SQSBroker(region_name="us-east-1") +app = FastStream(broker) + + +@broker.subscriber("my-queue") +async def handler(msg: str) -> None: + print(msg) + + +@app.after_startup +async def publish_hello() -> None: + await broker.publish("Hello, SQS!", "my-queue") +``` + +### Connection parameters + +`SQSBroker` accepts the same connection options as `aiobotocore`'s `create_client`: + +| Parameter | Description | +|-----------|-------------| +| `region_name` | AWS region, e.g. `"us-east-1"`. | +| `endpoint_url` | Custom endpoint — point this at **LocalStack** (`http://localhost:4566`) or ElasticMQ for local development. | +| `aws_access_key_id` / `aws_secret_access_key` / `aws_session_token` | Explicit credentials (otherwise resolved from the environment / `~/.aws`). | +| `use_ssl`, `verify`, `botocore_config` | Standard botocore client tuning. | +| `response_queue` | Queue used for [RPC replies](rpc.md). | + +### Local development with LocalStack + +```python linenums="1" +broker = SQSBroker( + endpoint_url="http://localhost:4566", + region_name="us-east-1", + aws_access_key_id="test", + aws_secret_access_key="test", +) +``` + +## Testing + +Use `TestSQSBroker` to route messages in memory — no AWS connection required: + +```python linenums="1" +import pytest +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() + + +@broker.subscriber("test-queue") +async def handler(msg: str) -> str: + return msg + "!" + + +@pytest.mark.asyncio +async def test_handler() -> None: + async with TestSQSBroker(broker) as br: + await br.publish("hello", "test-queue") + handler.mock.assert_called_once_with("hello") +``` diff --git a/docs/docs/en/sqs/publishing.md b/docs/docs/en/sqs/publishing.md new file mode 100644 index 00000000000..be986a4594d --- /dev/null +++ b/docs/docs/en/sqs/publishing.md @@ -0,0 +1,50 @@ +# Publishing + +Publish to a queue with `broker.publish`: + +```python linenums="1" +await broker.publish( + "payload", + "my-queue", + headers={"trace-id": "abc"}, + delay_seconds=5, +) +``` + +Headers are sent as SQS **MessageAttributes**. FastStream reserves the +`content-type`, `reply_to`, and `correlation_id` attribute names for transport +metadata. + +## Declared queues + +Pass an `SQSQueue` instead of a name to have FastStream create the queue (with +the given attributes) on startup: + +```python linenums="1" +from faststream.sqs import SQSQueue + +queue = SQSQueue(name="orders", visibility_timeout=60, message_retention_period=86400) +await broker.publish("data", queue) +``` + +## Publisher objects + +Register a reusable publisher with `@broker.publisher`: + +```python linenums="1" +publisher = broker.publisher("out-queue", headers={"source": "svc-a"}) + + +@publisher +@broker.subscriber("in-queue") +async def handler(msg: str) -> str: + return msg.upper() # return value is published to "out-queue" +``` + +## Batch publishing + +```python linenums="1" +await broker.publish_batch("a", "b", "c", queue="my-queue") +``` + +A single `SendMessageBatch` request carries up to 10 messages. diff --git a/docs/docs/en/sqs/rpc.md b/docs/docs/en/sqs/rpc.md new file mode 100644 index 00000000000..9bb29ae8f9d --- /dev/null +++ b/docs/docs/en/sqs/rpc.md @@ -0,0 +1,33 @@ +# Request / response + +SQS has no native request/reply, so FastStream implements RPC with a dedicated +**response queue** plus a `correlation_id`. Configure the response queue on the +broker, then call `broker.request`: + +```python linenums="1" +from faststream.sqs import SQSBroker + +broker = SQSBroker(region_name="us-east-1", response_queue="responses") + + +@broker.subscriber("echo") +async def echo(msg: str) -> str: + return f"reply: {msg}" + + +async def call() -> None: + response = await broker.request("ping", "echo", timeout=10.0) + assert await response.decode() == "reply: ping" +``` + +How it works: + +1. The producer sends the request with `reply_to` set to the response queue URL + and a generated `correlation_id`. +2. A background task long-polls the response queue, matching replies to pending + requests by `correlation_id`, and deletes them once consumed. +3. The handler's return value is published back to `reply_to`. + +!!! note "" + `broker.request` raises `FeatureNotSupportedException` if no `response_queue` + was configured. diff --git a/docs/docs/en/sqs/security.md b/docs/docs/en/sqs/security.md new file mode 100644 index 00000000000..a68f907e0ee --- /dev/null +++ b/docs/docs/en/sqs/security.md @@ -0,0 +1,36 @@ +# Security + +Credentials are usually resolved by `botocore` from the environment, an IAM +role, or `~/.aws/credentials`. You can also pass them explicitly or via a +FastStream security object. + +## Explicit credentials + +```python linenums="1" +broker = SQSBroker( + region_name="us-east-1", + aws_access_key_id="AKIA...", + aws_secret_access_key="...", + aws_session_token="...", # optional, for temporary credentials +) +``` + +## Security objects + +`SASLPlaintext` maps `username`/`password` onto the access key id / secret, and +`BaseSecurity` controls TLS: + +```python linenums="1" +from faststream.security import SASLPlaintext + +broker = SQSBroker( + region_name="us-east-1", + security=SASLPlaintext( + username="AKIA...", + password="...", + use_ssl=True, + ), +) +``` + +The configured security also drives the generated AsyncAPI schema. diff --git a/docs/docs/en/sqs/subscription.md b/docs/docs/en/sqs/subscription.md new file mode 100644 index 00000000000..227a3bd27cd --- /dev/null +++ b/docs/docs/en/sqs/subscription.md @@ -0,0 +1,45 @@ +# Subscription + +Each subscriber long-polls its queue in a background task and dispatches every +received message to your handler: + +```python linenums="1" +@broker.subscriber( + "my-queue", + wait_time_seconds=10, # long-poll wait (0-20) + max_messages=10, # messages per receive (1-10) + visibility_timeout=30, # per-receive visibility override +) +async def handler(msg: str) -> None: + ... +``` + +| Parameter | Description | +|-----------|-------------| +| `wait_time_seconds` | SQS `WaitTimeSeconds` for long polling. Higher values reduce empty receives and request cost. | +| `max_messages` | SQS `MaxNumberOfMessages` per receive. | +| `visibility_timeout` | How long a received message stays invisible to other consumers while being processed. | + +## Accessing message metadata + +```python linenums="1" +from faststream.sqs.annotations import SQSMessage + + +@broker.subscriber("my-queue") +async def handler(body: str, msg: SQSMessage) -> None: + print(msg.message_id, msg.headers, msg.correlation_id) +``` + +## Declaring queues on subscribe + +Pass an `SQSQueue`/`FifoQueue` to create the queue automatically on startup: + +```python linenums="1" +from faststream.sqs import SQSQueue + + +@broker.subscriber(SQSQueue(name="orders", visibility_timeout=60)) +async def handler(msg: str) -> None: + ... +``` diff --git a/docs/docs/navigation_template.txt b/docs/docs/navigation_template.txt index 8ae793fa492..749053fa330 100644 --- a/docs/docs/navigation_template.txt +++ b/docs/docs/navigation_template.txt @@ -99,6 +99,13 @@ search: - [Shared subscriptions](mqtt/shared.md) - [MQTT 3.1.1 vs 5.0](mqtt/versions.md) - [Security](mqtt/security.md) +- [AWS SQS](sqs/index.md) + - [Publishing](sqs/publishing.md) + - [Subscription](sqs/subscription.md) + - [FIFO queues](sqs/fifo.md) + - [Request / response](sqs/rpc.md) + - [Acknowledgement](sqs/ack.md) + - [Security](sqs/security.md) - [NATS](nats/index.md) - Subscription - [Direct](nats/examples/direct.md) diff --git a/faststream/asgi/factories/asyncapi/try_it_out.py b/faststream/asgi/factories/asyncapi/try_it_out.py index 80058d74099..5ccb47740c1 100644 --- a/faststream/asgi/factories/asyncapi/try_it_out.py +++ b/faststream/asgi/factories/asyncapi/try_it_out.py @@ -227,4 +227,9 @@ def _get_broker_registry() -> dict[ registry[MQTTBroker] = TestMQTTBroker + with suppress(ImportError): + from faststream.sqs import SQSBroker, TestSQSBroker + + registry[SQSBroker] = TestSQSBroker + return registry diff --git a/faststream/exceptions.py b/faststream/exceptions.py index ffdf6408918..5105af3c468 100644 --- a/faststream/exceptions.py +++ b/faststream/exceptions.py @@ -195,6 +195,11 @@ def __str__(self) -> str: pip install "faststream[nats]" """ +INSTALL_FASTSTREAM_SQS = """ +To use AWS SQS with FastStream, please install dependencies:\n +pip install "faststream[sqs]" +""" + INSTALL_UVICORN = """ To run FastStream ASGI App via CLI, please install uvicorn:\n pip install uvicorn diff --git a/faststream/sqs/__init__.py b/faststream/sqs/__init__.py new file mode 100644 index 00000000000..aada24ae6d2 --- /dev/null +++ b/faststream/sqs/__init__.py @@ -0,0 +1,34 @@ +try: + from faststream.sqs.annotations import SQSMessage + from faststream.sqs.broker.broker import SQSBroker + from faststream.sqs.broker.router import SQSPublisher, SQSRoute, SQSRouter + from faststream.sqs.response import SQSResponse + from faststream.sqs.schemas import ( + FifoQueue, + RedriveAllowPolicy, + RedrivePolicy, + SQSQueue, + ) + from faststream.sqs.testing import TestSQSBroker + +except ImportError as e: + if "aiobotocore" not in e.msg and "botocore" not in e.msg: + raise + + from faststream.exceptions import INSTALL_FASTSTREAM_SQS + + raise ImportError(INSTALL_FASTSTREAM_SQS) from e + +__all__ = ( + "FifoQueue", + "RedriveAllowPolicy", + "RedrivePolicy", + "SQSBroker", + "SQSMessage", + "SQSPublisher", + "SQSQueue", + "SQSResponse", + "SQSRoute", + "SQSRouter", + "TestSQSBroker", +) diff --git a/faststream/sqs/annotations.py b/faststream/sqs/annotations.py new file mode 100644 index 00000000000..f1f8a0244ea --- /dev/null +++ b/faststream/sqs/annotations.py @@ -0,0 +1,18 @@ +from typing import Annotated + +from faststream._internal.context import Context +from faststream.annotations import ContextRepo, Logger +from faststream.params import NoCast +from faststream.sqs.broker.broker import SQSBroker as SB # noqa: N814 +from faststream.sqs.message import SQSMessage as SM # noqa: N814 + +__all__ = ( + "ContextRepo", + "Logger", + "NoCast", + "SQSBroker", + "SQSMessage", +) + +SQSMessage = Annotated[SM, Context("message")] +SQSBroker = Annotated[SB, Context("broker")] diff --git a/faststream/sqs/broker/__init__.py b/faststream/sqs/broker/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/faststream/sqs/broker/broker.py b/faststream/sqs/broker/broker.py new file mode 100644 index 00000000000..7311c699f3c --- /dev/null +++ b/faststream/sqs/broker/broker.py @@ -0,0 +1,313 @@ +import asyncio +import logging +from collections.abc import Iterable, Sequence +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Optional, Union, cast + +from aiobotocore.session import get_session +from fast_depends import Provider, dependency_provider +from typing_extensions import override + +from faststream._internal.broker import BrokerUsecase +from faststream._internal.constants import EMPTY +from faststream._internal.context.repository import ContextRepo +from faststream._internal.di import FastDependsConfig +from faststream.message import gen_cor_id +from faststream.middlewares import AckPolicy +from faststream.response.publish_type import PublishType +from faststream.specification.schema import BrokerSpec +from faststream.sqs.broker.config import SQSBrokerConfig +from faststream.sqs.message import SQSRawMessage +from faststream.sqs.publisher.producer import SQSFastProducer +from faststream.sqs.response import SQSBatchPublishCommand, SQSPublishCommand +from faststream.sqs.schemas import SQSQueue +from faststream.sqs.security import parse_security +from faststream.sqs.subscriber.usecase import SQSSubscriber + +from .logging import make_sqs_logger_state +from .registrator import SQSRegistrator + +if TYPE_CHECKING: + from types import TracebackType + + from fast_depends.dependencies import Dependant + from fast_depends.library.serializer import SerializerProto + from types_aiobotocore_sqs import SQSClient + + from faststream._internal.basic_types import LoggerProto, SendableMessage + from faststream._internal.parser import CodecProto + from faststream._internal.types import BrokerMiddleware, CustomCallable + from faststream.security import BaseSecurity + from faststream.specification.schema.extra import Tag, TagDict + from faststream.sqs.message import SQSMessage + + +class SQSBroker( + SQSRegistrator, + BrokerUsecase[SQSRawMessage, "SQSClient"], +): + """AWS SQS broker for FastStream (backed by aiobotocore).""" + + def __init__( + self, + *, + region_name: str | None = None, + endpoint_url: str | None = None, + aws_access_key_id: str | None = None, + aws_secret_access_key: str | None = None, + aws_session_token: str | None = None, + use_ssl: bool = True, + verify: bool | str | None = None, + botocore_config: Any | None = None, + response_queue: Union[str, "SQSQueue", None] = None, + graceful_timeout: float | None = 15.0, + decoder: Optional["CustomCallable"] = None, + parser: Optional["CustomCallable"] = None, + codec: Optional["CodecProto"] = None, + dependencies: Iterable["Dependant"] = (), + middlewares: Sequence["BrokerMiddleware[Any, Any]"] = (), + routers: Iterable[SQSRegistrator] = (), + ack_policy: AckPolicy = EMPTY, + # AsyncAPI args + specification_url: str | None = None, + protocol: str | None = None, + protocol_version: str | None = None, + description: str | None = None, + tags: Iterable["Tag | TagDict"] = (), + security: Optional["BaseSecurity"] = None, + # logging args + logger: Optional["LoggerProto"] = EMPTY, + log_level: int = logging.INFO, + # FastDepends args + apply_types: bool = True, + serializer: Optional["SerializerProto"] = EMPTY, + provider: Optional["Provider"] = None, + context: Optional["ContextRepo"] = None, + ) -> None: + secure_kwargs = parse_security(security) + + connection_kwargs: dict[str, Any] = { + "region_name": region_name, + "endpoint_url": endpoint_url, + "aws_access_key_id": aws_access_key_id, + "aws_secret_access_key": aws_secret_access_key, + "aws_session_token": aws_session_token, + "use_ssl": use_ssl, + "verify": verify, + "config": botocore_config, + **secure_kwargs, + } + # botocore rejects unknown Nones for some keys; drop empties + connection_kwargs = {k: v for k, v in connection_kwargs.items() if v is not None} + + if specification_url is None: + specification_url = endpoint_url or ( + f"https://sqs.{region_name}.amazonaws.com" if region_name else "sqs" + ) + + self._response_queue = response_queue + self._client_cm: Any = None + self._response_task: asyncio.Task[Any] | None = None + + super().__init__( + **connection_kwargs, + routers=routers, + config=SQSBrokerConfig( + producer=SQSFastProducer(parser=parser, decoder=decoder), + broker_middlewares=middlewares, + broker_parser=parser, + broker_decoder=decoder, + broker_codec=codec, + logger=make_sqs_logger_state( + logger=logger, + log_level=log_level, + ), + fd_config=FastDependsConfig( + use_fastdepends=apply_types, + serializer=serializer, + provider=provider or dependency_provider, + context=context or ContextRepo(), + ), + broker_dependencies=dependencies, + graceful_timeout=graceful_timeout, + ack_policy=ack_policy, + extra_context={"broker": self}, + ), + specification=BrokerSpec( + description=description, + url=[specification_url], + protocol=protocol or "sqs", + protocol_version=protocol_version or "custom", + tags=tags, + security=security, + ), + ) + + @override + async def _connect(self) -> "SQSClient": + session = get_session() + self._client_cm = session.create_client("sqs", **self._connection_kwargs) + client: SQSClient = await self._client_cm.__aenter__() + self.config.connect(client) + return client + + async def _setup_response_queue(self) -> None: + if self._response_queue is None: + return + + if isinstance(self._response_queue, SQSQueue): + url = await self.config.declare_queue(self._response_queue) + else: + url = await self.config.get_queue_url(self._response_queue) + + cast("SQSFastProducer", self.config.producer).response_queue_url = url + self._response_task = asyncio.create_task(self._consume_responses(url)) + + async def _consume_responses(self, queue_url: str) -> None: + client = self.config.client + producer = cast("SQSFastProducer", self.config.producer) + while self.running: + with suppress(Exception): + resp = await client.receive_message( + QueueUrl=queue_url, + MaxNumberOfMessages=10, + WaitTimeSeconds=5, + MessageAttributeNames=["All"], + ) + for message in resp.get("Messages", []): + attrs = message.get("MessageAttributes", {}) or {} + cid = attrs.get("correlation_id", {}).get("StringValue", "") + if cid: + producer.resolve_response(cid, message) + await client.delete_message( + QueueUrl=queue_url, + ReceiptHandle=message["ReceiptHandle"], + ) + + @override + async def start(self) -> None: + await self.connect() + self.running = True + c = SQSSubscriber.build_log_context(None, "") + self.config.logger.log("Connection established", logging.INFO, c) + await self._setup_response_queue() + await super().start() + + @override + async def stop( + self, + exc_type: type[BaseException] | None = None, + exc_val: BaseException | None = None, + exc_tb: Optional["TracebackType"] = None, + ) -> None: + await super().stop(exc_type, exc_val, exc_tb) + + if self._response_task is not None and not self._response_task.done(): + self._response_task.cancel() + with suppress(asyncio.CancelledError): + await self._response_task + self._response_task = None + + if self._client_cm is not None: + with suppress(Exception): + await self._client_cm.__aexit__(exc_type, exc_val, exc_tb) + self._client_cm = None + + self._connection = None + self.config.disconnect() + + @override + async def ping(self, timeout: float | None = None) -> bool: + if self._connection is None: + return False + try: + await self._connection.list_queues(MaxResults=1) + except Exception: + return False + else: + return True + + @override + async def publish( + self, + message: "SendableMessage" = None, + queue: Union[str, "SQSQueue"] = "", + *, + headers: dict[str, str] | None = None, + correlation_id: str | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + reply_to: str = "", + ) -> Any: + """Publish a message to an SQS queue. + + Args: + message: Message body to send. + queue: Target queue name or an SQSQueue declaration. + headers: Message headers (sent as SQS MessageAttributes). + correlation_id: Correlation ID for message tracing. + group_id: MessageGroupId for FIFO queues. + deduplication_id: MessageDeduplicationId for FIFO queues. + delay_seconds: Delay before the message becomes visible (0-900). + reply_to: Response queue (name or URL) for reply-to flows. + """ + queue_name = queue.queue_name if isinstance(queue, SQSQueue) else queue + cmd = SQSPublishCommand( + message, + queue=queue_name, + headers=headers, + correlation_id=correlation_id or gen_cor_id(), + reply_to=reply_to, + group_id=group_id, + deduplication_id=deduplication_id, + delay_seconds=delay_seconds, + _publish_type=PublishType.PUBLISH, + ) + return await self._basic_publish(cmd, producer=self.config.producer) + + @override + async def request( + self, + message: "SendableMessage" = None, + queue: Union[str, "SQSQueue"] = "", + /, + timeout: float = 30.0, + correlation_id: str | None = None, + headers: dict[str, str] | None = None, + group_id: str | None = None, + ) -> "SQSMessage": + queue_name = queue.queue_name if isinstance(queue, SQSQueue) else queue + cmd = SQSPublishCommand( + message, + queue=queue_name, + correlation_id=correlation_id or gen_cor_id(), + headers=headers, + group_id=group_id, + timeout=timeout, + _publish_type=PublishType.REQUEST, + ) + msg: SQSMessage = await self._basic_request(cmd, producer=self.config.producer) + return msg + + @override + async def publish_batch( + self, + *messages: "SendableMessage", + queue: Union[str, "SQSQueue"] = "", + headers: dict[str, str] | None = None, + correlation_id: str | None = None, + group_id: str | None = None, + ) -> Any: + queue_name = queue.queue_name if isinstance(queue, SQSQueue) else queue + first, *rest = messages or (None,) + cmd = SQSBatchPublishCommand( + first, + *rest, + queue=queue_name, + headers=headers, + correlation_id=correlation_id or gen_cor_id(), + group_id=group_id, + _publish_type=PublishType.PUBLISH, + ) + return await self._basic_publish_batch(cmd, producer=self.config.producer) diff --git a/faststream/sqs/broker/config.py b/faststream/sqs/broker/config.py new file mode 100644 index 00000000000..05ea723595a --- /dev/null +++ b/faststream/sqs/broker/config.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from faststream._internal.configs import BrokerConfig +from faststream._internal.parser import DefaultCodec +from faststream.exceptions import IncorrectState +from faststream.sqs.publisher.producer import SQSFastProducer + +if TYPE_CHECKING: + from types_aiobotocore_sqs import SQSClient + + from faststream.sqs.schemas import SQSQueue + + +@dataclass(kw_only=True) +class SQSBrokerConfig(BrokerConfig): + producer: "SQSFastProducer" = field(default_factory=SQSFastProducer) + _client: "SQSClient | None" = field(default=None, init=False, repr=False) + _queue_urls: dict[str, str] = field(default_factory=dict, init=False, repr=False) + + @property + def client(self) -> "SQSClient": + if self._client is None: + msg = "SQS broker is not connected. Call connect() first." + raise IncorrectState(msg) + return self._client + + def connect(self, client: "SQSClient") -> None: + self._client = client + self.producer.connect( + client, + self.fd_config._serializer, + codec=self.broker_codec or DefaultCodec(), + queue_urls=self._queue_urls, + ) + + def disconnect(self) -> None: + self._client = None + self.producer.disconnect() + + async def get_queue_url(self, queue: str) -> str: + if queue.startswith(("http://", "https://")): + return queue + if queue in self._queue_urls: + return self._queue_urls[queue] + resp = await self.client.get_queue_url(QueueName=queue) + url = resp["QueueUrl"] + self._queue_urls[queue] = url + return url + + async def declare_queue(self, queue: "SQSQueue") -> str: + """Create the queue if needed and cache its URL.""" + resp = await self.client.create_queue( + QueueName=queue.queue_name, + Attributes=queue.to_attributes(), # type: ignore[arg-type] + ) + url = resp["QueueUrl"] + self._queue_urls[queue.queue_name] = url + return url diff --git a/faststream/sqs/broker/logging.py b/faststream/sqs/broker/logging.py new file mode 100644 index 00000000000..528355a2822 --- /dev/null +++ b/faststream/sqs/broker/logging.py @@ -0,0 +1,56 @@ +import logging +from functools import partial +from typing import TYPE_CHECKING, Any + +from faststream._internal.logger import DefaultLoggerStorage, make_logger_state +from faststream._internal.logger.logging import get_broker_logger + +if TYPE_CHECKING: + from faststream._internal.basic_types import LoggerProto + from faststream._internal.context import ContextRepo + + +class SQSParamsStorage(DefaultLoggerStorage): + def __init__(self) -> None: + super().__init__() + + self._max_queue_len = 4 + self.logger_log_level = logging.INFO + + def set_level(self, level: int) -> None: + self.logger_log_level = level + + def register_subscriber(self, params: dict[str, Any]) -> None: + self._max_queue_len = max( + self._max_queue_len, + len(params.get("queue", "")), + ) + + def get_logger(self, *, context: "ContextRepo") -> "LoggerProto": + if not (lg := self._get_logger_ref()): + message_id_ln = 10 + + lg = get_broker_logger( + name="sqs", + default_context={ + "queue": "", + }, + message_id_ln=message_id_ln, + fmt="".join(( + "%(asctime)s %(levelname)-8s - ", + f"%(queue)-{self._max_queue_len}s | ", + f"%(message_id)-{message_id_ln}s - ", + "%(message)s", + )), + context=context, + log_level=self.logger_log_level, + ) + self._logger_ref.add(lg) + + return lg + + +make_sqs_logger_state = partial( + make_logger_state, + default_storage_cls=SQSParamsStorage, +) diff --git a/faststream/sqs/broker/registrator.py b/faststream/sqs/broker/registrator.py new file mode 100644 index 00000000000..d9d045bfa2c --- /dev/null +++ b/faststream/sqs/broker/registrator.py @@ -0,0 +1,156 @@ +from collections.abc import Iterable, Sequence +from typing import TYPE_CHECKING, Any, Optional, Union, cast + +from typing_extensions import override + +from faststream._internal.broker.registrator import Registrator +from faststream._internal.constants import EMPTY +from faststream.middlewares import AckPolicy +from faststream.sqs.broker.config import SQSBrokerConfig +from faststream.sqs.message import SQSRawMessage +from faststream.sqs.publisher.factory import create_publisher +from faststream.sqs.subscriber.factory import create_subscriber + +if TYPE_CHECKING: + from fast_depends.dependencies import Dependant + + from faststream._internal.parser import CodecProto + from faststream._internal.types import BrokerMiddleware, CustomCallable + from faststream.sqs.publisher.usecase import SQSPublisher + from faststream.sqs.schemas import SQSQueue + from faststream.sqs.subscriber.usecase import SQSSubscriber + + +class SQSRegistrator(Registrator[SQSRawMessage, SQSBrokerConfig]): + """Includable to SQSBroker router.""" + + @override + def subscriber( # type: ignore[override] + self, + queue: Union[str, "SQSQueue"], + *, + wait_time_seconds: int = 5, + max_messages: int = 10, + visibility_timeout: int | None = None, + # broker arguments + ack_policy: AckPolicy = EMPTY, + no_reply: bool = False, + dependencies: Iterable["Dependant"] = (), + parser: Optional["CustomCallable"] = None, + decoder: Optional["CustomCallable"] = None, + codec: Optional["CodecProto"] = None, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + include_in_schema: bool = True, + ) -> "SQSSubscriber": + """Subscribe a handler to an SQS queue. + + Args: + queue: Queue name or an ``SQSQueue``/``FifoQueue`` declaration. + wait_time_seconds: Long-poll wait time (0-20). + max_messages: Max messages per receive (1-10). + visibility_timeout: Per-receive visibility timeout override. + ack_policy: Acknowledgement policy for message processing. + no_reply: Whether to disable FastStream RPC / reply-to responses. + dependencies: Dependencies list to apply to the subscriber. + parser: Custom parser to map raw messages to FastStream ones. + decoder: Function to decode FastStream message bytes to Python objects. + codec: Custom codec object. + persistent: Whether to retain the subscriber across broker restarts. + title: AsyncAPI subscriber object title. + description: AsyncAPI subscriber object description. + include_in_schema: Whether to include operation in AsyncAPI schema. + """ + subscriber = create_subscriber( + queue=queue, + wait_time_seconds=wait_time_seconds, + max_messages=max_messages, + visibility_timeout=visibility_timeout, + ack_policy=ack_policy, + no_reply=no_reply, + config=cast("SQSBrokerConfig", self.config), + title_=title, + description_=description, + include_in_schema=include_in_schema, + ) + + super().subscriber(subscriber, persistent=persistent) + + return subscriber.add_call( + parser_=parser or self._parser, + decoder_=decoder or self._decoder, + codec_=codec, + dependencies_=dependencies, + ) + + @override + def publisher( # type: ignore[override] + self, + queue: Union[str, "SQSQueue"], + *, + headers: dict[str, str] | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + schema: Any | None = None, + include_in_schema: bool = True, + ) -> "SQSPublisher": + """Create a persistent publisher object for the given SQS queue. + + Args: + queue: Queue name or an ``SQSQueue``/``FifoQueue`` declaration. + headers: Default headers to include in every published message. + group_id: Default ``MessageGroupId`` for FIFO queues. + deduplication_id: Default ``MessageDeduplicationId`` for FIFO queues. + delay_seconds: Default ``DelaySeconds`` for published messages. + persistent: Whether to retain the publisher across broker restarts. + title: AsyncAPI publisher object title. + description: AsyncAPI publisher object description. + schema: AsyncAPI publishing message type. + include_in_schema: Whether to include operation in AsyncAPI schema. + """ + publisher = create_publisher( + queue=queue, + headers=headers, + group_id=group_id, + deduplication_id=deduplication_id, + delay_seconds=delay_seconds, + broker_config=cast("SQSBrokerConfig", self.config), + title_=title, + description_=description, + schema_=schema, + include_in_schema=include_in_schema, + ) + super().publisher(publisher, persistent=persistent) + return publisher + + @override + def include_router( + self, + router: "SQSRegistrator", # type: ignore[override] + *, + prefix: str = "", + dependencies: Iterable["Dependant"] = (), + middlewares: Sequence["BrokerMiddleware[Any, Any]"] = (), + include_in_schema: bool | None = None, + ) -> None: + if not isinstance(router, SQSRegistrator): + msg = ( + f"Router must be an instance of SQSRegistrator, " + f"got {type(router).__name__} instead." + ) + raise TypeError(msg) + + super().include_router( + router, + prefix=prefix, + dependencies=dependencies, + middlewares=middlewares, + include_in_schema=include_in_schema, + ) diff --git a/faststream/sqs/broker/router.py b/faststream/sqs/broker/router.py new file mode 100644 index 00000000000..ca06fa06d0b --- /dev/null +++ b/faststream/sqs/broker/router.py @@ -0,0 +1,124 @@ +from collections.abc import Awaitable, Callable, Iterable, Sequence +from typing import TYPE_CHECKING, Any, Optional, Union + +from faststream._internal.broker.router import ( + ArgsContainer, + BrokerRouter, + SubscriberRoute, +) +from faststream._internal.constants import EMPTY +from faststream.middlewares import AckPolicy +from faststream.sqs.broker.config import SQSBrokerConfig + +from .registrator import SQSRegistrator + +if TYPE_CHECKING: + from fast_depends.dependencies import Dependant + + from faststream._internal.basic_types import SendableMessage + from faststream._internal.types import BrokerMiddleware, CustomCallable + from faststream.sqs.schemas import SQSQueue + + +class SQSPublisher(ArgsContainer): + """Delayed SQSPublisher registration object for use in SQSRoute.""" + + def __init__( + self, + queue: Union[str, "SQSQueue"], + *, + headers: dict[str, str] | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + persistent: bool = True, + title: str | None = None, + description: str | None = None, + schema: Any | None = None, + include_in_schema: bool = True, + ) -> None: + super().__init__( + queue, + headers=headers, + group_id=group_id, + deduplication_id=deduplication_id, + delay_seconds=delay_seconds, + persistent=persistent, + title=title, + description=description, + schema=schema, + include_in_schema=include_in_schema, + ) + + +class SQSRoute(SubscriberRoute): + """Class to store a delayed SQSBroker subscriber registration.""" + + def __init__( + self, + call: Callable[..., "SendableMessage"] + | Callable[..., Awaitable["SendableMessage"]], + queue: Union[str, "SQSQueue"], + *, + publishers: Iterable["SQSPublisher"] = (), + wait_time_seconds: int = 5, + max_messages: int = 10, + visibility_timeout: int | None = None, + ack_policy: AckPolicy = EMPTY, + no_reply: bool = False, + dependencies: Iterable["Dependant"] = (), + parser: Optional["CustomCallable"] = None, + decoder: Optional["CustomCallable"] = None, + persistent: bool = True, + title: str | None = None, + description: str | None = None, + include_in_schema: bool = True, + ) -> None: + super().__init__( + call, + queue, + publishers=publishers, + wait_time_seconds=wait_time_seconds, + max_messages=max_messages, + visibility_timeout=visibility_timeout, + ack_policy=ack_policy, + no_reply=no_reply, + dependencies=dependencies, + parser=parser, + decoder=decoder, + persistent=persistent, + title=title, + description=description, + include_in_schema=include_in_schema, + ) + + +class SQSRouter(SQSRegistrator, BrokerRouter["Any"]): + """Includable to SQSBroker router.""" + + def __init__( + self, + prefix: str = "", + handlers: Iterable[SQSRoute] = (), + *, + dependencies: Iterable["Dependant"] = (), + middlewares: Sequence["BrokerMiddleware[Any, Any]"] = (), + routers: Iterable[SQSRegistrator] = (), + parser: Optional["CustomCallable"] = None, + decoder: Optional["CustomCallable"] = None, + include_in_schema: bool | None = None, + ack_policy: AckPolicy = EMPTY, + ) -> None: + super().__init__( + handlers=handlers, + config=SQSBrokerConfig( + prefix=prefix, + ack_policy=ack_policy, + broker_dependencies=dependencies, + broker_middlewares=middlewares, + broker_parser=parser, + broker_decoder=decoder, + include_in_schema=include_in_schema, + ), + routers=routers, + ) diff --git a/faststream/sqs/fastapi/__init__.py b/faststream/sqs/fastapi/__init__.py new file mode 100644 index 00000000000..71787002e55 --- /dev/null +++ b/faststream/sqs/fastapi/__init__.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from faststream._internal.fastapi.context import Context, ContextRepo, Logger +from faststream.sqs.broker.broker import SQSBroker as SB # noqa: N814 +from faststream.sqs.message import SQSMessage as SM # noqa: N814 + +from .fastapi import SQSRouter + +__all__ = ( + "Context", + "ContextRepo", + "Logger", + "SQSBroker", + "SQSMessage", + "SQSRouter", +) + +SQSMessage = Annotated[SM, Context("message")] +SQSBroker = Annotated[SB, Context("broker")] diff --git a/faststream/sqs/fastapi/fastapi.py b/faststream/sqs/fastapi/fastapi.py new file mode 100644 index 00000000000..1eb07b93101 --- /dev/null +++ b/faststream/sqs/fastapi/fastapi.py @@ -0,0 +1,234 @@ +import logging +from collections.abc import Callable, Iterable, Sequence +from typing import TYPE_CHECKING, Any, Optional, Union, cast + +from fastapi.datastructures import Default +from fastapi.routing import APIRoute +from fastapi.utils import generate_unique_id +from starlette.responses import JSONResponse +from starlette.routing import BaseRoute +from typing_extensions import override + +from faststream._internal.constants import EMPTY +from faststream._internal.context import ContextRepo +from faststream._internal.fastapi.router import StreamRouter +from faststream.middlewares import AckPolicy +from faststream.sqs.broker.broker import SQSBroker +from faststream.sqs.message import SQSRawMessage + +if TYPE_CHECKING: + from enum import Enum + + from fast_depends.library.serializer import SerializerProto + from fastapi import params + from fastapi.types import IncEx + from starlette.responses import Response + from starlette.types import ASGIApp, Lifespan + + from faststream._internal.basic_types import LoggerProto + from faststream._internal.parser import CodecProto + from faststream._internal.types import BrokerMiddleware, CustomCallable + from faststream.security import BaseSecurity + from faststream.specification.base import SpecificationFactory + from faststream.specification.schema.extra import Tag, TagDict + from faststream.sqs.publisher.usecase import SQSPublisher + from faststream.sqs.schemas import SQSQueue + from faststream.sqs.subscriber.usecase import SQSSubscriber + + +class SQSRouter(StreamRouter[SQSRawMessage]): + """FastAPI router for the AWS SQS broker.""" + + broker_class = SQSBroker + broker: SQSBroker + + def __init__( + self, + *, + region_name: str | None = None, + endpoint_url: str | None = None, + aws_access_key_id: str | None = None, + aws_secret_access_key: str | None = None, + aws_session_token: str | None = None, + use_ssl: bool = True, + verify: bool | str | None = None, + botocore_config: Any | None = None, + response_queue: Union[str, "SQSQueue", None] = None, + # broker base args + graceful_timeout: float | None = 15.0, + decoder: Optional["CustomCallable"] = None, + parser: Optional["CustomCallable"] = None, + codec: Optional["CodecProto"] = None, + middlewares: Sequence["BrokerMiddleware[Any, Any]"] = (), + ack_policy: AckPolicy = EMPTY, + serializer: Optional["SerializerProto"] = EMPTY, + # AsyncAPI args + security: Optional["BaseSecurity"] = None, + specification_url: str | None = None, + protocol: str | None = None, + protocol_version: str | None = None, + description: str | None = None, + specification: Optional["SpecificationFactory"] = None, + specification_tags: Iterable[Union["Tag", "TagDict"]] = (), + # logging args + logger: Optional["LoggerProto"] = EMPTY, + log_level: int = logging.INFO, + # StreamRouter options + setup_state: bool = True, + schema_url: str | None = "/asyncapi", + context: ContextRepo | None = None, + # FastAPI args + prefix: str = "", + tags: list[Union[str, "Enum"]] | None = None, + dependencies: Sequence["params.Depends"] | None = None, + default_response_class: type["Response"] = Default(JSONResponse), + responses: dict[int | str, dict[str, Any]] | None = None, + callbacks: list[BaseRoute] | None = None, + routes: list[BaseRoute] | None = None, + redirect_slashes: bool = True, + default: Optional["ASGIApp"] = None, + dependency_overrides_provider: Any | None = None, + route_class: type["APIRoute"] = APIRoute, + on_startup: Sequence[Callable[[], Any]] | None = None, + on_shutdown: Sequence[Callable[[], Any]] | None = None, + lifespan: Optional["Lifespan[Any]"] = None, + deprecated: bool | None = None, + include_in_schema: bool = True, + generate_unique_id_function: Callable[["APIRoute"], str] = Default( + generate_unique_id + ), + ) -> None: + super().__init__( + region_name=region_name, + endpoint_url=endpoint_url, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + use_ssl=use_ssl, + verify=verify, + botocore_config=botocore_config, + response_queue=response_queue, + graceful_timeout=graceful_timeout, + decoder=decoder, + parser=parser, + codec=codec, + middlewares=middlewares, + ack_policy=ack_policy, + serializer=serializer, + schema_url=schema_url, + setup_state=setup_state, + context=context, + logger=logger, + log_level=log_level, + security=security, + protocol=protocol, + description=description, + protocol_version=protocol_version, + specification_tags=specification_tags, + specification_url=specification_url, + specification=specification, + # FastAPI args + prefix=prefix, + tags=tags, + dependencies=dependencies, + default_response_class=default_response_class, + responses=responses, + callbacks=callbacks, + routes=routes, + redirect_slashes=redirect_slashes, + default=default, + dependency_overrides_provider=dependency_overrides_provider, + route_class=route_class, + on_startup=on_startup, + on_shutdown=on_shutdown, + deprecated=deprecated, + include_in_schema=include_in_schema, + lifespan=lifespan, + generate_unique_id_function=generate_unique_id_function, + ) + + @override + def subscriber( # type: ignore[override] + self, + queue: Union[str, "SQSQueue"], + *, + wait_time_seconds: int = 5, + max_messages: int = 10, + visibility_timeout: int | None = None, + # broker arguments + ack_policy: AckPolicy = EMPTY, + no_reply: bool = False, + dependencies: Iterable["params.Depends"] = (), + parser: Optional["CustomCallable"] = None, + decoder: Optional["CustomCallable"] = None, + codec: Optional["CodecProto"] = None, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + include_in_schema: bool = True, + # FastAPI args + response_model: Any = Default(None), + response_model_include: Optional["IncEx"] = None, + response_model_exclude: Optional["IncEx"] = None, + response_model_by_alias: bool = True, + response_model_exclude_unset: bool = False, + response_model_exclude_defaults: bool = False, + response_model_exclude_none: bool = False, + ) -> "SQSSubscriber": + return cast( + "SQSSubscriber", + super().subscriber( + queue, # type: ignore[arg-type] + wait_time_seconds=wait_time_seconds, + max_messages=max_messages, + visibility_timeout=visibility_timeout, + ack_policy=ack_policy, + no_reply=no_reply, + dependencies=dependencies, + parser=parser, + decoder=decoder, + codec=codec, + persistent=persistent, + title=title, + description=description, + include_in_schema=include_in_schema, + # FastAPI args + response_model=response_model, + response_model_include=response_model_include, + response_model_exclude=response_model_exclude, + response_model_by_alias=response_model_by_alias, + response_model_exclude_unset=response_model_exclude_unset, + response_model_exclude_defaults=response_model_exclude_defaults, + response_model_exclude_none=response_model_exclude_none, + ), + ) + + @override + def publisher( # type: ignore[override] + self, + queue: Union[str, "SQSQueue"], + *, + headers: dict[str, str] | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + schema: Any | None = None, + include_in_schema: bool = True, + ) -> "SQSPublisher": + return self.broker.publisher( + queue, + headers=headers, + group_id=group_id, + deduplication_id=deduplication_id, + delay_seconds=delay_seconds, + persistent=persistent, + title=title, + description=description, + schema=schema, + include_in_schema=include_in_schema, + ) diff --git a/faststream/sqs/message.py b/faststream/sqs/message.py new file mode 100644 index 00000000000..ff346fb3f5e --- /dev/null +++ b/faststream/sqs/message.py @@ -0,0 +1,55 @@ +from typing import TYPE_CHECKING + +from faststream.message import StreamMessage + +if TYPE_CHECKING: + from types_aiobotocore_sqs import SQSClient + from types_aiobotocore_sqs.type_defs import MessageTypeDef + + SQSRawMessage = MessageTypeDef +else: + SQSRawMessage = dict + + +class SQSMessage(StreamMessage["SQSRawMessage"]): + """A message consumed from an SQS queue. + + Acknowledgement maps onto the SQS API: + + * ``ack`` -> ``DeleteMessage`` (message handled, remove it) + * ``nack`` -> ``ChangeMessageVisibility(0)`` (return for immediate redelivery) + * ``reject`` -> ``DeleteMessage`` (give up; DLQ is handled via RedrivePolicy) + """ + + # Set by SQSParser at parse time so ack/nack/reject can reach the queue. + sqs_client: "SQSClient | None" = None + queue_url: str = "" + + @property + def receipt_handle(self) -> str: + return self.raw_message.get("ReceiptHandle", "") + + async def ack(self) -> None: + if self.committed is None and self.sqs_client is not None and self.receipt_handle: + await self.sqs_client.delete_message( + QueueUrl=self.queue_url, + ReceiptHandle=self.receipt_handle, + ) + await super().ack() + + async def nack(self) -> None: + if self.committed is None and self.sqs_client is not None and self.receipt_handle: + await self.sqs_client.change_message_visibility( + QueueUrl=self.queue_url, + ReceiptHandle=self.receipt_handle, + VisibilityTimeout=0, + ) + await super().nack() + + async def reject(self) -> None: + if self.committed is None and self.sqs_client is not None and self.receipt_handle: + await self.sqs_client.delete_message( + QueueUrl=self.queue_url, + ReceiptHandle=self.receipt_handle, + ) + await super().reject() diff --git a/faststream/sqs/opentelemetry/__init__.py b/faststream/sqs/opentelemetry/__init__.py new file mode 100644 index 00000000000..d06128205a6 --- /dev/null +++ b/faststream/sqs/opentelemetry/__init__.py @@ -0,0 +1,3 @@ +from faststream.sqs.opentelemetry.middleware import SQSTelemetryMiddleware + +__all__ = ("SQSTelemetryMiddleware",) diff --git a/faststream/sqs/opentelemetry/middleware.py b/faststream/sqs/opentelemetry/middleware.py new file mode 100644 index 00000000000..dfc0d1217f4 --- /dev/null +++ b/faststream/sqs/opentelemetry/middleware.py @@ -0,0 +1,24 @@ +from opentelemetry.metrics import Meter, MeterProvider +from opentelemetry.trace import TracerProvider + +from faststream.opentelemetry.middleware import TelemetryMiddleware +from faststream.sqs.opentelemetry.provider import SQSTelemetrySettingsProvider +from faststream.sqs.response import SQSPublishCommand + + +class SQSTelemetryMiddleware(TelemetryMiddleware[SQSPublishCommand]): + def __init__( + self, + *, + tracer_provider: TracerProvider | None = None, + meter_provider: MeterProvider | None = None, + meter: Meter | None = None, + include_messages_counters: bool = True, + ) -> None: + super().__init__( + settings_provider_factory=lambda _: SQSTelemetrySettingsProvider(), + tracer_provider=tracer_provider, + meter_provider=meter_provider, + meter=meter, + include_messages_counters=include_messages_counters, + ) diff --git a/faststream/sqs/opentelemetry/provider.py b/faststream/sqs/opentelemetry/provider.py new file mode 100644 index 00000000000..42810f53cf3 --- /dev/null +++ b/faststream/sqs/opentelemetry/provider.py @@ -0,0 +1,54 @@ +from typing import TYPE_CHECKING, Any + +from opentelemetry.semconv.trace import SpanAttributes + +from faststream.opentelemetry import TelemetrySettingsProvider +from faststream.opentelemetry.consts import MESSAGING_DESTINATION_PUBLISH_NAME +from faststream.sqs.message import SQSRawMessage +from faststream.sqs.response import SQSPublishCommand + +if TYPE_CHECKING: + from faststream.message import StreamMessage + + +class SQSTelemetrySettingsProvider( + TelemetrySettingsProvider[SQSRawMessage, SQSPublishCommand], +): + __slots__ = ("messaging_system",) + + def __init__(self) -> None: + self.messaging_system = "aws_sqs" + + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[SQSRawMessage]", + ) -> dict[str, Any]: + return { + SpanAttributes.MESSAGING_SYSTEM: self.messaging_system, + SpanAttributes.MESSAGING_MESSAGE_ID: msg.message_id, + SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: msg.correlation_id, + SpanAttributes.MESSAGING_MESSAGE_PAYLOAD_SIZE_BYTES: len(msg.body), + MESSAGING_DESTINATION_PUBLISH_NAME: getattr(msg, "queue_url", ""), + } + + def get_consume_destination_name( + self, + msg: "StreamMessage[SQSRawMessage]", + ) -> str: + return getattr(msg, "queue_url", "") + + def get_publish_attrs_from_cmd( + self, + cmd: SQSPublishCommand, + ) -> dict[str, Any]: + return { + SpanAttributes.MESSAGING_SYSTEM: self.messaging_system, + SpanAttributes.MESSAGING_DESTINATION_NAME: cmd.destination, + SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: cmd.correlation_id, + } + + def get_publish_destination_name( + self, + cmd: SQSPublishCommand, + ) -> str: + return cmd.destination diff --git a/faststream/sqs/parser.py b/faststream/sqs/parser.py new file mode 100644 index 00000000000..f17a42373b0 --- /dev/null +++ b/faststream/sqs/parser.py @@ -0,0 +1,70 @@ +from typing import TYPE_CHECKING, Any + +from faststream.message import StreamMessage, decode_message + +from .message import SQSMessage + +if TYPE_CHECKING: + from types_aiobotocore_sqs import SQSClient + + from faststream._internal.basic_types import DecodedMessage + + from .message import SQSRawMessage + + +# Message attribute names FastStream reserves for transport metadata. +RESERVED_ATTRS = ("content-type", "reply_to", "correlation_id") + + +class SQSParser: + """Parses raw SQS messages into FastStream ``SQSMessage`` objects. + + ``client`` and ``queue_url`` are injected by the subscriber at start time + so the resulting message can ack/nack/reject against the right queue. + """ + + def __init__(self) -> None: + self.client: SQSClient | None = None + self.queue_url: str = "" + + def bind(self, client: "SQSClient", queue_url: str) -> None: + self.client = client + self.queue_url = queue_url + + async def parse_message(self, message: "SQSRawMessage") -> SQSMessage: + attributes: dict[str, Any] = message.get("MessageAttributes", {}) or {} + + headers: dict[str, Any] = {} + content_type: str | None = None + reply_to: str = "" + correlation_id: str | None = None + + for name, attr in attributes.items(): + value = attr.get("StringValue", "") + if name == "content-type": + content_type = value + elif name == "reply_to": + reply_to = value + elif name == "correlation_id": + correlation_id = value + else: + headers[name] = value + + body = message.get("Body", "") + raw_body = body.encode() if isinstance(body, str) else (body or b"") + + parsed = SQSMessage( + raw_message=message, + body=raw_body, + headers=headers, + content_type=content_type, + reply_to=reply_to, + correlation_id=correlation_id, + message_id=message.get("MessageId"), + ) + parsed.sqs_client = self.client + parsed.queue_url = self.queue_url + return parsed + + async def decode_message(self, msg: "StreamMessage[Any]") -> "DecodedMessage": + return decode_message(msg) diff --git a/faststream/sqs/prometheus/__init__.py b/faststream/sqs/prometheus/__init__.py new file mode 100644 index 00000000000..fe838932066 --- /dev/null +++ b/faststream/sqs/prometheus/__init__.py @@ -0,0 +1,3 @@ +from faststream.sqs.prometheus.middleware import SQSPrometheusMiddleware + +__all__ = ("SQSPrometheusMiddleware",) diff --git a/faststream/sqs/prometheus/middleware.py b/faststream/sqs/prometheus/middleware.py new file mode 100644 index 00000000000..5981ea3b25c --- /dev/null +++ b/faststream/sqs/prometheus/middleware.py @@ -0,0 +1,33 @@ +from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING, Any + +from faststream._internal.constants import EMPTY +from faststream.prometheus.middleware import PrometheusMiddleware +from faststream.sqs.message import SQSRawMessage +from faststream.sqs.prometheus.provider import SQSMetricsSettingsProvider +from faststream.sqs.response import SQSPublishCommand + +if TYPE_CHECKING: + from prometheus_client import CollectorRegistry + + +class SQSPrometheusMiddleware( + PrometheusMiddleware[SQSRawMessage, SQSPublishCommand], +): + def __init__( + self, + *, + registry: "CollectorRegistry", + app_name: str = EMPTY, + metrics_prefix: str = "faststream", + received_messages_size_buckets: Sequence[float] | None = None, + custom_labels: dict[str, str | Callable[[Any], str]] | None = None, + ) -> None: + super().__init__( + settings_provider_factory=lambda _: SQSMetricsSettingsProvider(), + registry=registry, + app_name=app_name, + metrics_prefix=metrics_prefix, + received_messages_size_buckets=received_messages_size_buckets, + custom_labels=custom_labels, + ) diff --git a/faststream/sqs/prometheus/provider.py b/faststream/sqs/prometheus/provider.py new file mode 100644 index 00000000000..eab590908eb --- /dev/null +++ b/faststream/sqs/prometheus/provider.py @@ -0,0 +1,33 @@ +from typing import TYPE_CHECKING + +from faststream.prometheus import ConsumeAttrs, MetricsSettingsProvider +from faststream.sqs.message import SQSRawMessage +from faststream.sqs.response import SQSPublishCommand + +if TYPE_CHECKING: + from faststream.message.message import StreamMessage + + +class SQSMetricsSettingsProvider( + MetricsSettingsProvider[SQSRawMessage, SQSPublishCommand], +): + __slots__ = ("messaging_system",) + + def __init__(self) -> None: + self.messaging_system = "aws_sqs" + + def get_consume_attrs_from_message( + self, + msg: "StreamMessage[SQSRawMessage]", + ) -> ConsumeAttrs: + return { + "destination_name": getattr(msg, "queue_url", ""), + "message_size": len(msg.body), + "messages_count": 1, + } + + def get_publish_destination_name_from_cmd( + self, + cmd: SQSPublishCommand, + ) -> str: + return cmd.destination diff --git a/faststream/sqs/publisher/__init__.py b/faststream/sqs/publisher/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/faststream/sqs/publisher/config.py b/faststream/sqs/publisher/config.py new file mode 100644 index 00000000000..19a227d5bf1 --- /dev/null +++ b/faststream/sqs/publisher/config.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field + +from faststream._internal.configs import ( + PublisherSpecificationConfig, + PublisherUsecaseConfig, +) +from faststream.sqs.broker.config import SQSBrokerConfig + + +@dataclass(kw_only=True) +class SQSPublisherSpecificationConfig(PublisherSpecificationConfig): + queue: str + + +@dataclass(kw_only=True) +class SQSPublisherConfig(PublisherUsecaseConfig): + _outer_config: "SQSBrokerConfig" = field(default_factory=SQSBrokerConfig) + + queue: str + headers: dict[str, str] | None + group_id: str | None + deduplication_id: str | None + delay_seconds: int diff --git a/faststream/sqs/publisher/factory.py b/faststream/sqs/publisher/factory.py new file mode 100644 index 00000000000..6771d95edf8 --- /dev/null +++ b/faststream/sqs/publisher/factory.py @@ -0,0 +1,49 @@ +from typing import TYPE_CHECKING, Any, Union + +from faststream.sqs.schemas import SQSQueue + +from .config import SQSPublisherConfig, SQSPublisherSpecificationConfig +from .specification import SQSPublisherSpecification +from .usecase import SQSPublisher + +if TYPE_CHECKING: + from faststream.sqs.broker.config import SQSBrokerConfig + + +def create_publisher( + *, + queue: Union[str, "SQSQueue"], + headers: dict[str, str] | None, + group_id: str | None, + deduplication_id: str | None, + delay_seconds: int, + broker_config: "SQSBrokerConfig", + # AsyncAPI args + schema_: Any | None, + title_: str | None, + description_: str | None, + include_in_schema: bool, +) -> SQSPublisher: + queue_name = queue.queue_name if isinstance(queue, SQSQueue) else queue + + publisher_config = SQSPublisherConfig( + queue=queue_name, + headers=headers, + group_id=group_id, + deduplication_id=deduplication_id, + delay_seconds=delay_seconds, + _outer_config=broker_config, + ) + + specification = SQSPublisherSpecification( + _outer_config=broker_config, + specification_config=SQSPublisherSpecificationConfig( + queue=queue_name, + schema_=schema_, + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ), + ) + + return SQSPublisher(publisher_config, specification) diff --git a/faststream/sqs/publisher/fake.py b/faststream/sqs/publisher/fake.py new file mode 100644 index 00000000000..eeecacd3498 --- /dev/null +++ b/faststream/sqs/publisher/fake.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING, Union + +from faststream._internal.endpoint.publisher.fake import FakePublisher +from faststream.sqs.response import SQSPublishCommand + +if TYPE_CHECKING: + from faststream._internal.producer import ProducerProto + from faststream.response.response import PublishCommand + + +class SQSFakePublisher(FakePublisher): + """Publisher used for RPC / reply-to responses in SQS.""" + + def __init__( + self, + producer: "ProducerProto[SQSPublishCommand]", + queue: str, + ) -> None: + super().__init__(producer=producer) + self.queue = queue + + def patch_command( + self, + cmd: Union["PublishCommand", "SQSPublishCommand"], + ) -> "SQSPublishCommand": + cmd = super().patch_command(cmd) + real_cmd = SQSPublishCommand.from_cmd(cmd) + real_cmd.destination = self.queue + return real_cmd diff --git a/faststream/sqs/publisher/producer.py b/faststream/sqs/publisher/producer.py new file mode 100644 index 00000000000..4ff3b8dedd5 --- /dev/null +++ b/faststream/sqs/publisher/producer.py @@ -0,0 +1,182 @@ +import asyncio +from typing import TYPE_CHECKING, Any, Optional + +import anyio +from typing_extensions import override + +from faststream._internal.endpoint.utils import ParserComposition +from faststream._internal.parser import DefaultCodec +from faststream._internal.producer import ProducerProto +from faststream.exceptions import FeatureNotSupportedException, IncorrectState +from faststream.message import gen_cor_id +from faststream.sqs.parser import SQSParser +from faststream.sqs.response import SQSPublishCommand + +if TYPE_CHECKING: + from fast_depends.library.serializer import SerializerProto + from types_aiobotocore_sqs import SQSClient + + from faststream._internal.parser import CodecProto + from faststream._internal.types import AsyncCallable, CustomCallable + + from .config import SQSPublisherConfig # noqa: F401 + + +class SQSFastProducer(ProducerProto[SQSPublishCommand]): + """Producer that sends messages to SQS queues via aiobotocore.""" + + _parser: "AsyncCallable" + _decoder: "AsyncCallable" + + def __init__( + self, + parser: Optional["CustomCallable"] = None, + decoder: Optional["CustomCallable"] = None, + ) -> None: + self.serializer: SerializerProto | None = None + self._client: SQSClient | None = None + self._queue_urls: dict[str, str] = {} + self.codec: CodecProto = DefaultCodec() + + # RPC state + self.response_queue_url: str | None = None + self._futures: dict[str, asyncio.Future[Any]] = {} + + default_parser = SQSParser() + self._parser = ParserComposition(parser, default_parser.parse_message) + self._decoder = ParserComposition(decoder, default_parser.decode_message) + + def connect( + self, + client: "SQSClient", + serializer: Optional["SerializerProto"], + codec: Optional["CodecProto"] = None, + queue_urls: dict[str, str] | None = None, + ) -> None: + self._client = client + self.serializer = serializer + self.codec = codec or DefaultCodec() + if queue_urls is not None: + self._queue_urls = queue_urls + + def disconnect(self) -> None: + self._client = None + self.serializer = None + self.response_queue_url = None + for future in self._futures.values(): + if not future.done(): + future.cancel() + self._futures.clear() + + @property + def _connected_client(self) -> "SQSClient": + if self._client is None: + msg = "Producer is not connected. Call connect() first." + raise IncorrectState(msg) + return self._client + + async def get_queue_url(self, queue: str) -> str: + # reply_to / declared queues already arrive as full URLs + if queue.startswith(("http://", "https://")): + return queue + if queue in self._queue_urls: + return self._queue_urls[queue] + resp = await self._connected_client.get_queue_url(QueueName=queue) + url = resp["QueueUrl"] + self._queue_urls[queue] = url + return url + + def _build_message_attributes( + self, + cmd: "SQSPublishCommand", + content_type: str | None, + ) -> dict[str, Any]: + attrs: dict[str, Any] = {} + for key, value in (cmd.headers or {}).items(): + attrs[key] = {"DataType": "String", "StringValue": str(value)} + if content_type: + attrs["content-type"] = {"DataType": "String", "StringValue": content_type} + if cmd.reply_to: + attrs["reply_to"] = {"DataType": "String", "StringValue": cmd.reply_to} + if cmd.correlation_id: + attrs["correlation_id"] = { + "DataType": "String", + "StringValue": cmd.correlation_id, + } + return attrs + + async def _build_send_kwargs(self, cmd: "SQSPublishCommand") -> dict[str, Any]: + payload, content_type = await self.codec.encode(cmd.body, self.serializer) + body = payload.decode() if isinstance(payload, bytes) else str(payload) + + kwargs: dict[str, Any] = { + "QueueUrl": await self.get_queue_url(cmd.queue), + "MessageBody": body, + "MessageAttributes": self._build_message_attributes(cmd, content_type), + } + if cmd.delay_seconds: + kwargs["DelaySeconds"] = cmd.delay_seconds + if cmd.group_id: + kwargs["MessageGroupId"] = cmd.group_id + if cmd.deduplication_id: + kwargs["MessageDeduplicationId"] = cmd.deduplication_id + return kwargs + + @override + async def publish(self, cmd: "SQSPublishCommand") -> Any: + kwargs = await self._build_send_kwargs(cmd) + return await self._connected_client.send_message(**kwargs) + + @override + async def publish_batch(self, cmd: "SQSPublishCommand") -> Any: + url = await self.get_queue_url(cmd.queue) + entries: list[dict[str, Any]] = [] + for idx, body in enumerate(cmd.batch_bodies): + payload, content_type = await self.codec.encode(body, self.serializer) + text = payload.decode() if isinstance(payload, bytes) else str(payload) + entry: dict[str, Any] = { + "Id": str(idx), + "MessageBody": text, + "MessageAttributes": self._build_message_attributes(cmd, content_type), + } + if cmd.group_id: + entry["MessageGroupId"] = cmd.group_id + if cmd.deduplication_id: + entry["MessageDeduplicationId"] = f"{cmd.deduplication_id}-{idx}" + entries.append(entry) + + return await self._connected_client.send_message_batch( + QueueUrl=url, + Entries=entries, # type: ignore[arg-type] + ) + + @override + async def request(self, cmd: "SQSPublishCommand") -> Any: + if self.response_queue_url is None: + msg = ( + "SQS RPC requires a response queue. " + "Pass `response_queue=...` to SQSBroker(...)." + ) + raise FeatureNotSupportedException(msg) + + correlation_id = cmd.correlation_id or gen_cor_id() + cmd.correlation_id = correlation_id + cmd.reply_to = self.response_queue_url + + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._futures[correlation_id] = future + + try: + await self.publish(cmd) + with anyio.fail_after(cmd.timeout or 30.0): + return await future + finally: + self._futures.pop(correlation_id, None) + + def resolve_response(self, correlation_id: str, raw_message: Any) -> bool: + """Resolve a pending RPC future. Returns True if a waiter was found.""" + future = self._futures.get(correlation_id) + if future is not None and not future.done(): + future.set_result(raw_message) + return True + return False diff --git a/faststream/sqs/publisher/specification.py b/faststream/sqs/publisher/specification.py new file mode 100644 index 00000000000..2b7dc4fee6c --- /dev/null +++ b/faststream/sqs/publisher/specification.py @@ -0,0 +1,48 @@ +from faststream._internal.endpoint.publisher import PublisherSpecification +from faststream.specification.asyncapi.utils import resolve_payloads +from faststream.specification.schema import Message, Operation, PublisherSpec +from faststream.specification.schema.bindings import ( + ChannelBinding, + OperationBinding, + sqs as sqs_bindings, +) +from faststream.sqs.broker.config import SQSBrokerConfig + +from .config import SQSPublisherSpecificationConfig + + +class SQSPublisherSpecification( + PublisherSpecification[SQSBrokerConfig, SQSPublisherSpecificationConfig], +): + @property + def queue(self) -> str: + return f"{self._outer_config.prefix}{self.config.queue}" + + @property + def name(self) -> str: + if self.config.title_: + return self.config.title_ + return f"{self.queue}:Publisher" + + def get_schema(self) -> dict[str, PublisherSpec]: + payloads = self.get_payloads() + + return { + self.name: PublisherSpec( + description=self.config.description_, + operation=Operation( + message=Message( + title=f"{self.name}:Message", + payload=resolve_payloads(payloads, "Publisher"), + ), + bindings=OperationBinding( + sqs=sqs_bindings.OperationBinding(), + ), + ), + bindings=ChannelBinding( + sqs=sqs_bindings.ChannelBinding( + queue={"name": self.queue}, + ), + ), + ), + } diff --git a/faststream/sqs/publisher/usecase.py b/faststream/sqs/publisher/usecase.py new file mode 100644 index 00000000000..d9f819d7144 --- /dev/null +++ b/faststream/sqs/publisher/usecase.py @@ -0,0 +1,105 @@ +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Union + +from typing_extensions import override + +from faststream._internal.endpoint.publisher import PublisherUsecase +from faststream.message import gen_cor_id +from faststream.response.publish_type import PublishType +from faststream.sqs.response import SQSPublishCommand + +if TYPE_CHECKING: + from faststream._internal.basic_types import SendableMessage + from faststream._internal.endpoint.publisher import PublisherSpecification + from faststream._internal.types import PublisherMiddleware + from faststream.response.response import PublishCommand + from faststream.sqs.broker.config import SQSBrokerConfig + + from .config import SQSPublisherConfig + + +class SQSPublisher(PublisherUsecase): + """Publisher for an SQS queue.""" + + _outer_config: "SQSBrokerConfig" + + def __init__( + self, + config: "SQSPublisherConfig", + specification: "PublisherSpecification[Any, Any]", + ) -> None: + super().__init__(config, specification) + + self.queue = config.queue + self.headers = config.headers or {} + self.group_id = config.group_id + self.deduplication_id = config.deduplication_id + self.delay_seconds = config.delay_seconds + + @override + async def publish( + self, + message: "SendableMessage", + queue: str = "", + *, + headers: dict[str, str] | None = None, + correlation_id: str | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int | None = None, + ) -> Any: + cmd = SQSPublishCommand( + message, + queue=queue or self.queue, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + group_id=group_id or self.group_id, + deduplication_id=deduplication_id or self.deduplication_id, + delay_seconds=delay_seconds if delay_seconds is not None else self.delay_seconds, + _publish_type=PublishType.PUBLISH, + ) + return await self._basic_publish( + cmd, + producer=self._outer_config.producer, + _extra_middlewares=(), + ) + + @override + async def _publish( + self, + cmd: Union["PublishCommand", "SQSPublishCommand"], + *, + _extra_middlewares: Iterable["PublisherMiddleware"], + ) -> None: + """Called in the subscriber (reply-to) flow only.""" + cmd = SQSPublishCommand.from_cmd(cmd) + cmd.destination = cmd.destination or self.queue + cmd.add_headers(self.headers, override=False) + cmd.group_id = cmd.group_id or self.group_id + + await self._basic_publish( + cmd, + producer=self._outer_config.producer, + _extra_middlewares=_extra_middlewares, + ) + + @override + async def request( + self, + message: "SendableMessage", + queue: str = "", + *, + correlation_id: str | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = 30.0, + ) -> Any: + cmd = SQSPublishCommand( + message, + queue=queue or self.queue, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + group_id=self.group_id, + timeout=timeout, + _publish_type=PublishType.REQUEST, + ) + return await self._basic_request(cmd, producer=self._outer_config.producer) diff --git a/faststream/sqs/response.py b/faststream/sqs/response.py new file mode 100644 index 00000000000..fa023ccd78f --- /dev/null +++ b/faststream/sqs/response.py @@ -0,0 +1,162 @@ +from typing import TYPE_CHECKING, Union + +from typing_extensions import override + +from faststream.response.publish_type import PublishType +from faststream.response.response import ( + BatchPublishCommand, + PublishCommand, + Response, +) + +if TYPE_CHECKING: + from faststream._internal.basic_types import SendableMessage + + +class SQSResponse(Response): + def __init__( + self, + body: "SendableMessage", + *, + headers: dict[str, str] | None = None, + correlation_id: str | None = None, + delay_seconds: int = 0, + group_id: str | None = None, + deduplication_id: str | None = None, + ) -> None: + super().__init__(body=body, headers=headers, correlation_id=correlation_id) + self.delay_seconds = delay_seconds + self.group_id = group_id + self.deduplication_id = deduplication_id + + @override + def as_publish_command(self) -> "SQSPublishCommand": + return SQSPublishCommand( + self.body, + headers=self.headers, + correlation_id=self.correlation_id, + _publish_type=PublishType.PUBLISH, + queue="", + delay_seconds=self.delay_seconds, + group_id=self.group_id, + deduplication_id=self.deduplication_id, + ) + + +class SQSPublishCommand(PublishCommand): + def __init__( + self, + message: "SendableMessage", + *, + queue: str = "", + correlation_id: str | None = None, + headers: dict[str, str] | None = None, + reply_to: str = "", + delay_seconds: int = 0, + group_id: str | None = None, + deduplication_id: str | None = None, + timeout: float | None = 30.0, + _publish_type: PublishType, + ) -> None: + super().__init__( + body=message, + destination=queue, + correlation_id=correlation_id, + headers=headers, + reply_to=reply_to, + _publish_type=_publish_type, + ) + self.delay_seconds = delay_seconds + self.group_id = group_id + self.deduplication_id = deduplication_id + self.timeout = timeout + + @property + def queue(self) -> str: + return self.destination + + @classmethod + def from_cmd( + cls, + cmd: Union["PublishCommand", "SQSPublishCommand"], + ) -> "SQSPublishCommand": + if isinstance(cmd, SQSPublishCommand): + return cmd + + return cls( + cmd.body, + queue=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + reply_to=cmd.reply_to, + timeout=getattr(cmd, "timeout", None), + _publish_type=cmd.publish_type, + ) + + def __repr__(self) -> str: + body: list[str] = [ + f"body='{self.body}'", + f"queue='{self.destination}'", + ] + if self.group_id: + body.append(f"group_id='{self.group_id}'") + if self.reply_to: + body.append(f"reply_to='{self.reply_to}'") + body.extend(( + f"headers={self.headers}", + f"correlation_id='{self.correlation_id}'", + )) + return f"{self.__class__.__name__}({', '.join(body)})" + + +class SQSBatchPublishCommand(BatchPublishCommand): + def __init__( + self, + body: "SendableMessage", + /, + *bodies: "SendableMessage", + queue: str = "", + correlation_id: str | None = None, + headers: dict[str, str] | None = None, + reply_to: str = "", + group_id: str | None = None, + deduplication_id: str | None = None, + _publish_type: PublishType = PublishType.PUBLISH, + ) -> None: + super().__init__( + body, + *bodies, + destination=queue, + correlation_id=correlation_id, + headers=headers, + reply_to=reply_to, + _publish_type=_publish_type, + ) + self.group_id = group_id + self.deduplication_id = deduplication_id + self.delay_seconds = 0 + + @property + def queue(self) -> str: + return self.destination + + @classmethod + def from_cmd( + cls, + cmd: Union["PublishCommand", "SQSBatchPublishCommand"], + *, + batch: bool = False, + ) -> "SQSBatchPublishCommand": + if isinstance(cmd, SQSBatchPublishCommand): + return cmd + + body, extra_bodies = cls._parse_bodies(cmd.body, batch=batch) + return cls( + body, + *extra_bodies, + queue=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + reply_to=cmd.reply_to, + _publish_type=cmd.publish_type, + ) diff --git a/faststream/sqs/schemas/__init__.py b/faststream/sqs/schemas/__init__.py new file mode 100644 index 00000000000..ca1773bebff --- /dev/null +++ b/faststream/sqs/schemas/__init__.py @@ -0,0 +1,13 @@ +from faststream.sqs.schemas.queue import ( + FifoQueue, + RedriveAllowPolicy, + RedrivePolicy, + SQSQueue, +) + +__all__ = ( + "FifoQueue", + "RedriveAllowPolicy", + "RedrivePolicy", + "SQSQueue", +) diff --git a/faststream/sqs/schemas/queue.py b/faststream/sqs/schemas/queue.py new file mode 100644 index 00000000000..10f9f850ea9 --- /dev/null +++ b/faststream/sqs/schemas/queue.py @@ -0,0 +1,105 @@ +from typing import Literal + +from pydantic import BaseModel, Field + +from faststream._internal._compat import dump_json + + +class RedrivePolicy(BaseModel): + """SQS redrive policy — routes failed messages to a dead-letter queue.""" + + dead_letter_target_arn: str = Field(alias="deadLetterTargetArn") + max_receive_count: int = Field(default=10, alias="maxReceiveCount", gt=0) + + model_config = {"populate_by_name": True} + + +class RedriveAllowPolicy(BaseModel): + """SQS redrive-allow policy — which source queues may use this DLQ.""" + + redrive_permission: Literal["allowAll", "denyAll", "byQueue"] = Field( + default="allowAll", + alias="redrivePermission", + ) + source_queue_arns: list[str] | None = Field( + default=None, + alias="sourceQueueArns", + max_length=10, + ) + + model_config = {"populate_by_name": True} + + +class SQSQueue(BaseModel): + """A standard SQS queue declaration. + + Can be passed to ``@broker.subscriber`` / ``broker.publish`` in place of a + plain queue name. ``to_attributes()`` renders the AWS ``Attributes`` dict + used by ``create_queue``. + """ + + name: str + # whether this is a FIFO queue; excluded from the attributes dump + fifo: bool = Field(default=False, exclude=True) + + delay_seconds: int | None = Field(default=None, alias="DelaySeconds", ge=0, le=900) + maximum_message_size: int | None = Field( + default=None, alias="MaximumMessageSize", ge=1024, le=262144 + ) + message_retention_period: int | None = Field( + default=None, alias="MessageRetentionPeriod", ge=60, le=1209600 + ) + receive_message_wait_time_seconds: int | None = Field( + default=None, alias="ReceiveMessageWaitTimeSeconds", ge=0, le=20 + ) + visibility_timeout: int | None = Field( + default=None, alias="VisibilityTimeout", ge=0, le=43200 + ) + redrive_policy: RedrivePolicy | None = Field(default=None, alias="RedrivePolicy") + redrive_allow_policy: RedriveAllowPolicy | None = Field( + default=None, alias="RedriveAllowPolicy" + ) + kms_master_key_id: str | None = Field(default=None, alias="KmsMasterKeyId") + sqs_managed_sse_enabled: bool | None = Field( + default=None, alias="SqsManagedSseEnabled" + ) + + model_config = {"populate_by_name": True} + + @property + def queue_name(self) -> str: + """The full queue name AWS expects (FIFO queues require a .fifo suffix).""" + if self.fifo and not self.name.endswith(".fifo"): + return f"{self.name}.fifo" + return self.name + + def to_attributes(self) -> dict[str, str]: + """Render the AWS ``Attributes`` dict for ``create_queue``.""" + raw = self.model_dump(by_alias=True, exclude_none=True, exclude={"name"}) + attributes: dict[str, str] = {} + for key, value in raw.items(): + if isinstance(value, bool): + attributes[key] = str(value).lower() + elif isinstance(value, (dict, list)): + attributes[key] = dump_json(value).decode() + else: + attributes[key] = str(value) + if self.fifo: + attributes["FifoQueue"] = "true" + return attributes + + +class FifoQueue(SQSQueue): + """A FIFO SQS queue declaration (``.fifo`` suffix is added automatically).""" + + fifo: bool = Field(default=True, exclude=True) + + content_based_deduplication: bool | None = Field( + default=None, alias="ContentBasedDeduplication" + ) + deduplication_scope: Literal["messageGroup", "queue"] | None = Field( + default=None, alias="DeduplicationScope" + ) + fifo_throughput_limit: Literal["perQueue", "perMessageGroupId"] | None = Field( + default=None, alias="FifoThroughputLimit" + ) diff --git a/faststream/sqs/security.py b/faststream/sqs/security.py new file mode 100644 index 00000000000..7f1d79c0208 --- /dev/null +++ b/faststream/sqs/security.py @@ -0,0 +1,27 @@ +from typing import Any + +from faststream.security import BaseSecurity, SASLPlaintext + + +def parse_security(security: BaseSecurity | None) -> dict[str, Any]: + """Map a FastStream security object onto aiobotocore client kwargs.""" + if security is None: + return {} + if isinstance(security, SASLPlaintext): + return _parse_sasl_plaintext(security) + if isinstance(security, BaseSecurity): + return _parse_base_security(security) + msg = f"SQSBroker does not support {type(security)}" + raise NotImplementedError(msg) + + +def _parse_base_security(security: BaseSecurity) -> dict[str, Any]: + return {"use_ssl": security.use_ssl} + + +def _parse_sasl_plaintext(security: SASLPlaintext) -> dict[str, Any]: + return { + **_parse_base_security(security), + "aws_access_key_id": security.username, + "aws_secret_access_key": security.password, + } diff --git a/faststream/sqs/subscriber/__init__.py b/faststream/sqs/subscriber/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/faststream/sqs/subscriber/config.py b/faststream/sqs/subscriber/config.py new file mode 100644 index 00000000000..2cd78cc951b --- /dev/null +++ b/faststream/sqs/subscriber/config.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Optional + +from faststream._internal.configs import ( + SubscriberSpecificationConfig, + SubscriberUsecaseConfig, +) +from faststream._internal.constants import EMPTY +from faststream.middlewares.acknowledgement.config import AckPolicy +from faststream.sqs.broker.config import SQSBrokerConfig + +if TYPE_CHECKING: + from faststream.sqs.schemas import SQSQueue + + +@dataclass(kw_only=True) +class SQSSubscriberSpecificationConfig(SubscriberSpecificationConfig): + queue: str + + +@dataclass(kw_only=True) +class SQSSubscriberConfig(SubscriberUsecaseConfig): + _outer_config: "SQSBrokerConfig" = field(default_factory=SQSBrokerConfig) + + queue: str + declare: Optional["SQSQueue"] = None + wait_time_seconds: int = 5 + max_messages: int = 10 + visibility_timeout: int | None = None + + @property + def ack_policy(self) -> AckPolicy: + if self._ack_policy is EMPTY: + if self._outer_config.ack_policy is not EMPTY: + return self._outer_config.ack_policy + return AckPolicy.ACK + return self._ack_policy diff --git a/faststream/sqs/subscriber/factory.py b/faststream/sqs/subscriber/factory.py new file mode 100644 index 00000000000..367fffe8458 --- /dev/null +++ b/faststream/sqs/subscriber/factory.py @@ -0,0 +1,59 @@ +from typing import TYPE_CHECKING, Any, Union + +from faststream._internal.endpoint.subscriber.call_item import CallsCollection +from faststream.sqs.schemas import SQSQueue + +from .config import SQSSubscriberConfig, SQSSubscriberSpecificationConfig +from .specification import SQSSubscriberSpecification +from .usecase import SQSSubscriber + +if TYPE_CHECKING: + from faststream.middlewares import AckPolicy + from faststream.sqs.broker.config import SQSBrokerConfig + + +def create_subscriber( + *, + queue: Union[str, "SQSQueue"], + wait_time_seconds: int, + max_messages: int, + visibility_timeout: int | None, + # Subscriber args + ack_policy: "AckPolicy", + no_reply: bool, + config: "SQSBrokerConfig", + # AsyncAPI args + title_: str | None = None, + description_: str | None = None, + include_in_schema: bool = True, +) -> SQSSubscriber: + declare: SQSQueue | None = queue if isinstance(queue, SQSQueue) else None + queue_name = queue.queue_name if isinstance(queue, SQSQueue) else queue + + subscriber_config = SQSSubscriberConfig( + queue=queue_name, + declare=declare, + wait_time_seconds=wait_time_seconds, + max_messages=max_messages, + visibility_timeout=visibility_timeout, + no_reply=no_reply, + _outer_config=config, + _ack_policy=ack_policy, + ) + + specification_config = SQSSubscriberSpecificationConfig( + queue=queue_name, + title_=title_, + description_=description_, + include_in_schema=include_in_schema, + ) + + calls = CallsCollection[Any]() + + specification = SQSSubscriberSpecification( + config, + specification_config, + calls, + ) + + return SQSSubscriber(subscriber_config, specification, calls) diff --git a/faststream/sqs/subscriber/specification.py b/faststream/sqs/subscriber/specification.py new file mode 100644 index 00000000000..a88273d3cd4 --- /dev/null +++ b/faststream/sqs/subscriber/specification.py @@ -0,0 +1,61 @@ +from typing import TYPE_CHECKING, Any + +from faststream._internal.endpoint.subscriber import SubscriberSpecification +from faststream.specification.asyncapi.utils import resolve_payloads +from faststream.specification.schema import Message, Operation, SubscriberSpec +from faststream.specification.schema.bindings import ( + ChannelBinding, + OperationBinding, + sqs as sqs_bindings, +) +from faststream.sqs.broker.config import SQSBrokerConfig + +from .config import SQSSubscriberSpecificationConfig + +if TYPE_CHECKING: + from faststream._internal.endpoint.subscriber.call_item import CallsCollection + + +class SQSSubscriberSpecification( + SubscriberSpecification[SQSBrokerConfig, SQSSubscriberSpecificationConfig], +): + def __init__( + self, + _outer_config: "SQSBrokerConfig", + specification_config: "SQSSubscriberSpecificationConfig", + calls: "CallsCollection[Any]", + ) -> None: + super().__init__(_outer_config, specification_config, calls) + + @property + def queue(self) -> str: + return f"{self._outer_config.prefix}{self.config.queue}" + + @property + def name(self) -> str: + if self.config.title_: + return self.config.title_ + return f"{self.queue}:{self.call_name}" + + def get_schema(self) -> dict[str, SubscriberSpec]: + payloads = self.get_payloads() + + return { + self.name: SubscriberSpec( + description=self.description, + operation=Operation( + message=Message( + title=f"{self.name}:Message", + payload=resolve_payloads(payloads), + ), + bindings=OperationBinding( + sqs=sqs_bindings.OperationBinding(), + ), + ), + bindings=ChannelBinding( + sqs=sqs_bindings.ChannelBinding( + queue={"name": self.queue}, + ), + ), + ), + } diff --git a/faststream/sqs/subscriber/usecase.py b/faststream/sqs/subscriber/usecase.py new file mode 100644 index 00000000000..b3df351e7e8 --- /dev/null +++ b/faststream/sqs/subscriber/usecase.py @@ -0,0 +1,184 @@ +from collections.abc import AsyncIterator, Sequence +from contextlib import suppress +from typing import TYPE_CHECKING, Any + +import anyio +from botocore.exceptions import BotoCoreError, ClientError +from typing_extensions import override + +from faststream._internal.endpoint.subscriber import SubscriberUsecase +from faststream._internal.endpoint.subscriber.mixins import TasksMixin +from faststream._internal.endpoint.utils import process_msg +from faststream.sqs.parser import SQSParser +from faststream.sqs.publisher.fake import SQSFakePublisher + +if TYPE_CHECKING: + from faststream._internal.endpoint.publisher import PublisherProto + from faststream._internal.endpoint.subscriber import SubscriberSpecification + from faststream._internal.endpoint.subscriber.call_item import CallsCollection + from faststream.message import StreamMessage + from faststream.sqs.broker.config import SQSBrokerConfig + from faststream.sqs.message import SQSMessage, SQSRawMessage + + from .config import SQSSubscriberConfig + + +class SQSSubscriber(TasksMixin, SubscriberUsecase["SQSRawMessage"]): + """Polling subscriber for an SQS queue (long-poll receive loop).""" + + _outer_config: "SQSBrokerConfig" + + def __init__( + self, + config: "SQSSubscriberConfig", + specification: "SubscriberSpecification[Any, Any]", + calls: "CallsCollection[SQSRawMessage]", + ) -> None: + self._sqs_parser = SQSParser() + config.parser = self._sqs_parser.parse_message + config.decoder = self._sqs_parser.decode_message + super().__init__(config, specification, calls) + + self._queue = config.queue + self._declare = config.declare + self._wait_time_seconds = config.wait_time_seconds + self._max_messages = config.max_messages + self._visibility_timeout = config.visibility_timeout + self._queue_url: str = "" + + @property + def queue(self) -> str: + return f"{self._outer_config.prefix}{self._queue}" + + def _make_response_publisher( + self, + message: "StreamMessage[Any]", + ) -> Sequence["PublisherProto"]: + return ( + SQSFakePublisher( + producer=self._outer_config.producer, + queue=message.reply_to, + ), + ) + + @staticmethod + def build_log_context( + message: "StreamMessage[SQSRawMessage] | None", + queue: str = "", + ) -> dict[str, str]: + return { + "queue": queue, + "message_id": getattr(message, "message_id", ""), + } + + def get_log_context( + self, + message: "StreamMessage[SQSRawMessage] | None", + ) -> dict[str, str]: + return self.build_log_context(message=message, queue=self.queue) + + async def _resolve_queue_url(self) -> str: + if self._declare is not None: + return await self._outer_config.declare_queue(self._declare) + return await self._outer_config.get_queue_url(self.queue) + + @override + async def start(self) -> None: + await super().start() + + self._queue_url = await self._resolve_queue_url() + self._sqs_parser.bind(self._outer_config.client, self._queue_url) + self._parser = self._sqs_parser.parse_message + self._decoder = self._sqs_parser.decode_message + + if self.calls: + self.add_task(self._consume_loop) + + self._post_start() + + def _receive_kwargs(self) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "QueueUrl": self._queue_url, + "MaxNumberOfMessages": self._max_messages, + "WaitTimeSeconds": self._wait_time_seconds, + "MessageAttributeNames": ["All"], + } + if self._visibility_timeout is not None: + kwargs["VisibilityTimeout"] = self._visibility_timeout + return kwargs + + async def _receive(self) -> list["SQSRawMessage"]: + resp = await self._outer_config.client.receive_message(**self._receive_kwargs()) + return resp.get("Messages", []) + + async def _consume_loop(self) -> None: + backoff = 1.0 + while self.running: + try: + messages = await self._receive() + except (ClientError, BotoCoreError) as e: + self._outer_config.logger.log( + f"SQS receive failed, retrying in {backoff:.0f}s: {e}", + extra=self.get_log_context(None), + ) + await anyio.sleep(backoff) + backoff = min(backoff * 2, 30.0) + continue + + backoff = 1.0 + for message in messages: + await self.consume(message) + + @override + async def get_one( + self, + *, + timeout: float = 5.0, + ) -> "StreamMessage[SQSRawMessage] | None": + assert not self.calls, ( + "You can't use `get_one` method if subscriber has registered handlers." + ) + + if not self._queue_url: + self._queue_url = await self._resolve_queue_url() + self._sqs_parser.bind(self._outer_config.client, self._queue_url) + + raw_msg: SQSRawMessage | None = None + with anyio.move_on_after(timeout): + resp = await self._outer_config.client.receive_message( + QueueUrl=self._queue_url, + MaxNumberOfMessages=1, + WaitTimeSeconds=min(int(timeout), 20), + MessageAttributeNames=["All"], + ) + messages = resp.get("Messages", []) + raw_msg = messages[0] if messages else None + + context = self._outer_config.fd_config.context + return await process_msg( + msg=raw_msg, + middlewares=(m(raw_msg, context=context) for m in self._broker_middlewares), + parser=self._sqs_parser.parse_message, + decoder=self._sqs_parser.decode_message, + ) + + @override + async def __aiter__(self) -> AsyncIterator["SQSMessage"]: # type: ignore[override] + if not self._queue_url: + self._queue_url = await self._resolve_queue_url() + self._sqs_parser.bind(self._outer_config.client, self._queue_url) + + context = self._outer_config.fd_config.context + while True: + with suppress(ClientError, BotoCoreError): + for raw_msg in await self._receive(): + msg: SQSMessage = await process_msg( # type: ignore[assignment] + msg=raw_msg, + middlewares=( + m(raw_msg, context=context) + for m in self._broker_middlewares + ), + parser=self._sqs_parser.parse_message, + decoder=self._sqs_parser.decode_message, + ) + yield msg diff --git a/faststream/sqs/testing.py b/faststream/sqs/testing.py new file mode 100644 index 00000000000..331da6f16f1 --- /dev/null +++ b/faststream/sqs/testing.py @@ -0,0 +1,209 @@ +import asyncio +from collections.abc import Iterable, Iterator, Sequence +from contextlib import contextmanager +from typing import TYPE_CHECKING, Any, Optional, cast +from unittest.mock import AsyncMock, MagicMock + +import anyio +from typing_extensions import override + +from faststream._internal.endpoint.utils import ParserComposition +from faststream._internal.parser import DefaultCodec +from faststream._internal.testing.broker import TestBroker, change_producer +from faststream.exceptions import SubscriberNotFound +from faststream.message import gen_cor_id +from faststream.sqs.broker.broker import SQSBroker +from faststream.sqs.parser import SQSParser +from faststream.sqs.publisher.producer import SQSFastProducer +from faststream.sqs.response import SQSPublishCommand + +if TYPE_CHECKING: + from fast_depends.library.serializer import SerializerProto + + from faststream._internal.basic_types import SendableMessage + from faststream._internal.parser import CodecProto + from faststream.sqs.publisher.usecase import SQSPublisher + from faststream.sqs.subscriber.usecase import SQSSubscriber + +__all__ = ("TestSQSBroker",) + + +def _queue_matches(handler_queue: str, destination: str) -> bool: + return handler_queue.removesuffix(".fifo") == destination.removesuffix(".fifo") + + +class TestSQSBroker(TestBroker[SQSBroker]): + """In-memory test double for SQSBroker. + + Routes published messages directly to matching subscribers (by queue + name) without any AWS connection. + """ + + def create_publisher_fake_subscriber( + self, + broker: SQSBroker, + publisher: "SQSPublisher", + ) -> tuple["SQSSubscriber", bool]: + sub: SQSSubscriber | None = None + for handler in (s for b in self.brokers for s in b.subscribers): + handler = cast("SQSSubscriber", handler) + if _queue_matches(handler.queue, publisher.queue): + sub = handler + break + + if sub is None: + is_real = False + sub = broker.subscriber(publisher.queue, persistent=False) + else: + is_real = True + + return sub, is_real + + @contextmanager + def _patch_producer(self, broker: SQSBroker) -> Iterator[None]: + fake_producer = FakeProducer(broker, self.brokers) + with change_producer(broker.config.broker_config, fake_producer): + yield + + async def _fake_connect( # type: ignore[override] + self, + broker: SQSBroker, + *args: Any, + **kwargs: Any, + ) -> MagicMock: + fake_client = MagicMock() + + async def _echo_url(*, QueueName: str, **_: Any) -> dict[str, str]: # noqa: N803 + return {"QueueUrl": QueueName} + + async def _idle(**_: Any) -> dict[str, Any]: + await asyncio.sleep(1e9) + return {} # pragma: no cover + + fake_client.get_queue_url = AsyncMock(side_effect=_echo_url) + fake_client.create_queue = AsyncMock(side_effect=_echo_url) + fake_client.receive_message = AsyncMock(side_effect=_idle) + fake_client.delete_message = AsyncMock(return_value={}) + fake_client.change_message_visibility = AsyncMock(return_value={}) + + broker.config.broker_config._client = fake_client + return fake_client + + +class FakeProducer(SQSFastProducer): + """In-memory producer that routes messages directly to matching subscribers.""" + + def __init__( + self, + broker: SQSBroker, + brokers: Sequence[SQSBroker], + ) -> None: + self.broker = broker + self.brokers = brokers + self.serializer: SerializerProto | None = None + self._client = None + self._queue_urls = {} + self.response_queue_url = None + self._futures = {} + + default = SQSParser() + self._parser = ParserComposition(broker._parser, default.parse_message) + self._decoder = ParserComposition(broker._decoder, default.decode_message) + self.codec = broker.config.broker_codec or DefaultCodec() + + @property + def subscribers(self) -> Iterable["SQSSubscriber"]: + return (cast("SQSSubscriber", s) for b in self.brokers for s in b.subscribers) + + @override + async def publish(self, cmd: "SQSPublishCommand") -> None: + msg = await build_message( + message=cmd.body, + queue=cmd.destination, + correlation_id=cmd.correlation_id, + reply_to=cmd.reply_to, + headers=cmd.headers, + serializer=self.broker.config.fd_config._serializer, + codec=self.codec, + ) + + for handler in self.subscribers: + if _queue_matches(handler.queue, cmd.destination): + await handler.process_message(msg) + + @override + async def publish_batch(self, cmd: "SQSPublishCommand") -> None: + for body in cmd.batch_bodies: + await self.publish( + SQSPublishCommand( + body, + queue=cmd.destination, + headers=cmd.headers, + correlation_id=gen_cor_id(), + group_id=getattr(cmd, "group_id", None), + _publish_type=cmd.publish_type, + ), + ) + + @override + async def request(self, cmd: "SQSPublishCommand") -> Any: + msg = await build_message( + message=cmd.body, + queue=cmd.destination, + correlation_id=cmd.correlation_id, + headers=cmd.headers, + serializer=self.broker.config.fd_config._serializer, + codec=self.codec, + ) + + for handler in self.subscribers: + if not _queue_matches(handler.queue, cmd.destination): + continue + + with anyio.fail_after(cmd.timeout or 30.0): + result = await handler.process_message(msg) + + return await build_message( + message=result.body, + queue=cmd.destination, + correlation_id=result.correlation_id, + headers=result.headers, + serializer=self.broker.config.fd_config._serializer, + codec=self.codec, + ) + + raise SubscriberNotFound + + +async def build_message( + message: "SendableMessage", + queue: str, + *, + correlation_id: str | None = None, + reply_to: str = "", + headers: dict[str, str] | None = None, + serializer: Optional["SerializerProto"] = None, + codec: Optional["CodecProto"] = None, +) -> dict[str, Any]: + """Build a fake raw SQS message dict from publish parameters.""" + if codec is None: + codec = DefaultCodec() + payload, content_type = await codec.encode(message, serializer=serializer) + body = payload.decode() if isinstance(payload, bytes) else str(payload) + + attributes: dict[str, Any] = {} + for key, value in (headers or {}).items(): + attributes[key] = {"DataType": "String", "StringValue": str(value)} + if content_type: + attributes["content-type"] = {"DataType": "String", "StringValue": content_type} + if reply_to: + attributes["reply_to"] = {"DataType": "String", "StringValue": reply_to} + cid = correlation_id or gen_cor_id() + attributes["correlation_id"] = {"DataType": "String", "StringValue": cid} + + return { + "MessageId": gen_cor_id(), + "ReceiptHandle": gen_cor_id(), + "Body": body, + "MessageAttributes": attributes, + } diff --git a/pyproject.toml b/pyproject.toml index a2cec5674f8..89a3c319e85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ keywords = [ "nats", "redis", "mqtt", + "sqs", "asyncapi", "framework", "message brokers", @@ -87,9 +88,12 @@ cli = [ otel = ["opentelemetry-sdk>=1.24.0,<2.0.0"] prometheus = ["prometheus-client>=0.20.0,<0.30.0"] +sqs = [ + "aiobotocore>=2.13,<3", +] [dependency-groups] -optionals = ["faststream[rabbit,kafka,confluent,nats,redis,mqtt,otel,cli,prometheus]"] +optionals = ["faststream[rabbit,kafka,confluent,nats,redis,mqtt,sqs,otel,cli,prometheus]"] docs = [ "mkdocs-material==9.7.1", @@ -123,6 +127,7 @@ lint = [ "types-Pygments", "types-docutils", "types-aiofiles", + "types-aiobotocore[sqs]>=3.7.0", ] test-core = [ @@ -214,6 +219,7 @@ markers = [ "redis", "redis_cluster", "mqtt", + "sqs", "slow", "connected", "all", diff --git a/tests/brokers/sqs/__init__.py b/tests/brokers/sqs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/brokers/sqs/basic.py b/tests/brokers/sqs/basic.py new file mode 100644 index 00000000000..6d7a46ad36d --- /dev/null +++ b/tests/brokers/sqs/basic.py @@ -0,0 +1,23 @@ +from typing import Any + +from faststream.sqs.broker.broker import SQSBroker +from faststream.sqs.broker.router import SQSRouter +from faststream.sqs.testing import TestSQSBroker +from tests.brokers.base.basic import BaseTestcaseConfig + + +class SQSTestcaseConfig(BaseTestcaseConfig): + def get_broker( + self, + apply_types: bool = False, + **kwargs: Any, + ) -> SQSBroker: + return SQSBroker(region_name="us-east-1", apply_types=apply_types, **kwargs) + + def get_router(self, **kwargs: Any) -> SQSRouter: + return SQSRouter(**kwargs) + + +class SQSMemoryTestcaseConfig(SQSTestcaseConfig): + def patch_broker(self, *brokers: SQSBroker, **kwargs: Any) -> TestSQSBroker: + return TestSQSBroker(*brokers, **kwargs) diff --git a/tests/brokers/sqs/conftest.py b/tests/brokers/sqs/conftest.py new file mode 100644 index 00000000000..9779c48956c --- /dev/null +++ b/tests/brokers/sqs/conftest.py @@ -0,0 +1,25 @@ +import os +from dataclasses import dataclass + +import pytest + +from faststream.sqs.broker.router import SQSRouter + + +@dataclass +class Settings: + # LocalStack defaults; override via env for real AWS / other emulators. + endpoint_url: str = os.environ.get("SQS_ENDPOINT_URL", "http://localhost:4566") + region_name: str = os.environ.get("AWS_REGION", "us-east-1") + aws_access_key_id: str = os.environ.get("AWS_ACCESS_KEY_ID", "test") + aws_secret_access_key: str = os.environ.get("AWS_SECRET_ACCESS_KEY", "test") + + +@pytest.fixture(scope="session") +def settings() -> Settings: + return Settings() + + +@pytest.fixture() +def router() -> SQSRouter: + return SQSRouter() diff --git a/tests/brokers/sqs/test_connect.py b/tests/brokers/sqs/test_connect.py new file mode 100644 index 00000000000..cd9e593bfd3 --- /dev/null +++ b/tests/brokers/sqs/test_connect.py @@ -0,0 +1,64 @@ +import asyncio + +import pytest + +from faststream.sqs import FifoQueue, SQSBroker, SQSQueue +from tests.marks import require_sqs + +from .conftest import Settings + + +def _broker(settings: Settings) -> SQSBroker: + return SQSBroker( + endpoint_url=settings.endpoint_url, + region_name=settings.region_name, + aws_access_key_id=settings.aws_access_key_id, + aws_secret_access_key=settings.aws_secret_access_key, + ) + + +@require_sqs +@pytest.mark.sqs() +@pytest.mark.connected() +@pytest.mark.asyncio() +class TestSQSConnect: + async def test_ping(self, settings: Settings) -> None: + broker = _broker(settings) + async with broker: + assert await broker.ping(timeout=5.0) + + async def test_publish_consume_ack(self, settings: Settings, queue: str) -> None: + broker = _broker(settings) + event = asyncio.Event() + received: list[str] = [] + + @broker.subscriber(SQSQueue(name=queue)) + async def handler(msg: str) -> None: + received.append(msg) + event.set() + + async with broker: + await broker.start() + await broker.publish("hello", queue) + await asyncio.wait_for(event.wait(), timeout=15.0) + + assert received == ["hello"] + + async def test_fifo(self, settings: Settings, queue: str) -> None: + broker = _broker(settings) + event = asyncio.Event() + received: list[str] = [] + + fifo = FifoQueue(name=queue, content_based_deduplication=True) + + @broker.subscriber(fifo) + async def handler(msg: str) -> None: + received.append(msg) + event.set() + + async with broker: + await broker.start() + await broker.publish("ordered", fifo, group_id="group-1") + await asyncio.wait_for(event.wait(), timeout=15.0) + + assert received == ["ordered"] diff --git a/tests/brokers/sqs/test_testclient.py b/tests/brokers/sqs/test_testclient.py new file mode 100644 index 00000000000..20d6f5f9abc --- /dev/null +++ b/tests/brokers/sqs/test_testclient.py @@ -0,0 +1,97 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import FifoQueue, SQSBroker, SQSRoute, SQSRouter, TestSQSBroker +from tests.marks import require_sqs + + +@require_sqs +@pytest.mark.sqs() +@pytest.mark.asyncio() +class TestSQSTestClient: + async def test_pub_sub(self, queue: str) -> None: + broker = SQSBroker() + + @broker.subscriber(queue) + async def handler(msg: str) -> str: + return msg + "-ok" + + async with TestSQSBroker(broker) as br: + await br.publish("hello", queue) + handler.mock.assert_called_once_with("hello") + + async def test_publisher_decorator(self, queue: str) -> None: + broker = SQSBroker() + out = queue + "-out" + + @broker.subscriber(out) + async def downstream(msg: str) -> None: ... + + @broker.publisher(out) + @broker.subscriber(queue) + async def handler(msg: str) -> str: + return msg.upper() + + async with TestSQSBroker(broker) as br: + await br.publish("abc", queue) + handler.mock.assert_called_once_with("abc") + downstream.mock.assert_called_once_with("ABC") + + async def test_request_rpc(self, queue: str) -> None: + broker = SQSBroker(response_queue="responses") + + @broker.subscriber(queue) + async def handler(msg: str) -> str: + return f"reply:{msg}" + + async with TestSQSBroker(broker) as br: + response = await br.request("ping", queue) + assert await response.decode() == "reply:ping" + + async def test_router_prefix(self, queue: str) -> None: + broker = SQSBroker() + received: list[str] = [] + + async def handler(msg: str) -> None: + received.append(msg) + + router = SQSRouter(prefix="pref-", handlers=[SQSRoute(handler, queue)]) + broker.include_router(router) + + async with TestSQSBroker(broker) as br: + await br.publish("data", f"pref-{queue}") + assert received == ["data"] + + async def test_fifo_group_id(self, queue: str) -> None: + broker = SQSBroker() + fifo = FifoQueue(name=queue) + + @broker.subscriber(fifo) + async def handler(msg: str) -> None: ... + + async with TestSQSBroker(broker) as br: + await br.publish("m", fifo, group_id="g1") + handler.mock.assert_called_once_with("m") + + async def test_no_match_skips_handler(self, queue: str) -> None: + broker = SQSBroker() + + @broker.subscriber(queue) + async def handler(msg: str) -> None: ... + + async with TestSQSBroker(broker) as br: + await br.publish("x", queue + "-other") + handler.mock.assert_not_called() + + async def test_app_runs(self, queue: str) -> None: + broker = SQSBroker() + app = FastStream(broker) + + @broker.subscriber(queue) + async def handler(msg: str) -> None: ... + + async with TestSQSBroker(broker): + await broker.start() + await broker.publish("hi", queue) + handler.mock.assert_called_once_with("hi") + assert app.broker is broker diff --git a/tests/marks.py b/tests/marks.py index e80962b70b0..f5a5f6d42d8 100644 --- a/tests/marks.py +++ b/tests/marks.py @@ -103,3 +103,16 @@ not HAS_MQTT, reason="requires zmqtt", ) + + +try: + from faststream.sqs import SQSBroker # noqa: F401 +except ImportError: + HAS_SQS = False +else: + HAS_SQS = True + +require_sqs = pytest.mark.skipif( + not HAS_SQS, + reason="requires aiobotocore", +) diff --git a/uv.lock b/uv.lock index f74b81b371f..127ddd8ace4 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,179 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/05/256fa313f48bed075056d13593b92ce804be05d75f4f312be24edb82860a/aio_pika-9.6.2-py3-none-any.whl", hash = "sha256:2a5478af920d169795071c9c09c7542cd8cdece60438cf7804533dcbcce93b7f", size = 56269, upload-time = "2026-03-22T19:03:19.558Z" }, ] +[[package]] +name = "aiobotocore" +version = "2.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/f8/99fa90d9c25b78292899fd4946fce97b6353838b5ecc139ad8ba1436e70c/aiobotocore-2.26.0.tar.gz", hash = "sha256:50567feaf8dfe2b653570b4491f5bc8c6e7fb9622479d66442462c021db4fadc", size = 122026, upload-time = "2025-11-28T07:54:59.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/58/3bf0b7d474607dc7fd67dd1365c4e0f392c8177eaf4054e5ddee3ebd53b5/aiobotocore-2.26.0-py3-none-any.whl", hash = "sha256:a793db51c07930513b74ea7a95bd79aaa42f545bdb0f011779646eafa216abec", size = 87333, upload-time = "2025-11-28T07:54:58.457Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "async-timeout", marker = "python_full_version < '3.11'" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/ab/93ce242f899b68c51b0578c027aafa791ab3614cb9345fa5d37b5f5c8e3e/aiohttp-3.14.0.tar.gz", hash = "sha256:2882de819734c715fd1b9c11c97e09fa020d14438203d1d354d8ed1702791c9b", size = 7940674, upload-time = "2026-06-01T19:41:02.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/f0/f81190ba488cd106c2fc6d92680e56bb223bbbbf1e6908c2617011290112/aiohttp-3.14.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:692e409052e7436029bbb32977cd7c5bf806ac5fa4085b973996785ffadad33c", size = 760606, upload-time = "2026-06-01T19:36:39.054Z" }, + { url = "https://files.pythonhosted.org/packages/f6/54/444d37eebf0f15db661ca44ec7caf93962f3c5ca92eb4c9a5d888b70aaa2/aiohttp-3.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:40af7ebe53c7990e110dc4ad03566b12c3ac996254298a3d39046dd69cfcb2c2", size = 514677, upload-time = "2026-06-01T19:36:42.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d1/da280e23321c132c0a3fa7c8cc2830621d79174edc64c829443346489a36/aiohttp-3.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02cb2ffbb7da32f82e21ad9952669c45bd88a80e0878264c2f59fe1c6fb2badd", size = 510155, upload-time = "2026-06-01T19:36:44.072Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/2e36d54d0991ec5bba451444004591ee0af58cb1662a3a81c562878b9c1f/aiohttp-3.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2514cb7195f6d7c219339635bea71ae47d1569b051300d32df9dcfabcdb869", size = 1699947, upload-time = "2026-06-01T19:36:45.762Z" }, + { url = "https://files.pythonhosted.org/packages/57/95/a31d8ea1a0b9ecc084f5a7dd0b431ce64ef585918bb7bdc82afe11843877/aiohttp-3.14.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:30e8b7eeb42d02c120ca90d6c6e076a221a16b70a6dac9ae44c7ab5104cc7fe4", size = 1664364, upload-time = "2026-06-01T19:36:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/01/f6/5de3ddffc87a9e8d09b3be38fbd6dd1a736b2ad477a7e787dcb85f57f338/aiohttp-3.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63e38be0d75a654deaa06be32fb4cab883a4222940be1d05861b6717679cbadb", size = 1761186, upload-time = "2026-06-01T19:36:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/03c5438ec35d7e3a4f33fe895d6c3ec7540a7cec46065f21851211e1ee4d/aiohttp-3.14.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1210d4c87cc00128160c7384ab41877a701295b97cffa6362f908a49b6e8a7ca", size = 1849727, upload-time = "2026-06-01T19:36:51.478Z" }, + { url = "https://files.pythonhosted.org/packages/22/32/5a05303b0874458920b73f48b8779cc3a93d503f121b38dcc0456dbd698c/aiohttp-3.14.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a78a77366ed158a0a54b076990e575d7b7cdb728cbfd02711eadab150f2269f", size = 1708197, upload-time = "2026-06-01T19:36:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/478f169488d61414c0a05e7fe423b59ae3d9dcc933d1f0e4acc2c5d5bc3e/aiohttp-3.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f4d2038c64f36df96cfd3fa0937910e231eafbf897e70a06c155a817bb632fa6", size = 1578147, upload-time = "2026-06-01T19:36:55.154Z" }, + { url = "https://files.pythonhosted.org/packages/1d/af/b20af85765658972d3337834bd5eebba91b962794f2b4fc3e0ee8c85c0e1/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4714c70067a08b604d0bf3bc4dfdf82e52944afab41d0428d460862763d2f79b", size = 1665836, upload-time = "2026-06-01T19:36:56.94Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a3/771879cfd59948f4544b172189048905feff802f20f1c6c5411e998a3e06/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f79bfd2847513a7ac801bbafd1de02348a37926ac439eeb4bfe96fcff4eada15", size = 1680335, upload-time = "2026-06-01T19:36:58.642Z" }, + { url = "https://files.pythonhosted.org/packages/f4/16/582e36ad1d32133cd40659f3bc98e71c22179665a1cfbbb4713bce339c06/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:25e9f1d2465a210d60edb64d7b204a147e85d4c194eecef3d1604fb5ace678ce", size = 1731180, upload-time = "2026-06-01T19:37:00.583Z" }, + { url = "https://files.pythonhosted.org/packages/11/bc/80708fe3f64a07a2c306a42fc7b009118a952709761d215f6d1b4c57195b/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b5314743ebe926c2fda35d0a298c565c885505f6635c2a30936363404cf274a7", size = 1565805, upload-time = "2026-06-01T19:37:02.446Z" }, + { url = "https://files.pythonhosted.org/packages/57/8f/8d25897f8273a32fe4ad40a8885eec4f397377ed46e8e383078169f60316/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:28eee8de1d69711c53116df8202f1c2aa0e3f80ef912a88fc18d159d53e7110b", size = 1742496, upload-time = "2026-06-01T19:37:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7d/c341d32ab2dec56c8478740695743dc6c21b383cace9376a3eab16311a07/aiohttp-3.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89ed35666c95d3efe1955056afcde09e62a57a34e2a4398b17f9f6c1564f0b25", size = 1691240, upload-time = "2026-06-01T19:37:06.277Z" }, + { url = "https://files.pythonhosted.org/packages/37/0f/a81207dd7a2d4a4f645b3a3f8b5a1da1159dc63117ffb137b698fd6df50f/aiohttp-3.14.0-cp310-cp310-win32.whl", hash = "sha256:5e4646e9a6af29af354204011bf5769cb0276ec5b64653e42f90b3e13845169f", size = 454686, upload-time = "2026-06-01T19:37:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/842357f2afb9c915715c6f5775239d987f5d0f845abf7675fa794e0a9d40/aiohttp-3.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:22a8d06f204e0518a586d770032db3c7043c9ba3693081b3e3ad425e1458d594", size = 478677, upload-time = "2026-06-01T19:37:09.652Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d1/330fb22c9535ec177b52396905131c6e39447244b6ca876262939af668ef/aiohttp-3.14.0-cp310-cp310-win_arm64.whl", hash = "sha256:4acfc34bd4d3c58754fc9f22ff1b5e92aabce68f3d4bf7b71a0b732d9bceb78a", size = 450364, upload-time = "2026-06-01T19:37:11.279Z" }, + { url = "https://files.pythonhosted.org/packages/67/47/7727bfe8db93f8835a001bd4359d8480cc68d1259b8bce334668f8be97bd/aiohttp-3.14.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54bf3522d6f7351e55f89a62d5c2bf138ad557b031670266c5df604ae88e0b5a", size = 759147, upload-time = "2026-06-01T19:37:12.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f2/cd3fedff6fade73d71df9ec908c210cec518ef90fd00289250684b90aecf/aiohttp-3.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0746d9fb0ac4fdef643a84494efe3f06d50335dd8c7a530228b86448aae0a803", size = 513705, upload-time = "2026-06-01T19:37:14.633Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fe/49746b6b610144a06323bebd8e1211a390310d8c69b98dd6d52df341bc3e/aiohttp-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f3a96b6d39a4872222beee72e1df41d2ff886ae96152cf3e757ef8c5673ef0e", size = 509627, upload-time = "2026-06-01T19:37:16.385Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3f/28f2f6cf3d5c0e7b01b27140d0e7873fd11fb341169ad3ce78ad04aba628/aiohttp-3.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d336820adbb914debbc90a1d8c1bfc4bea55996aecf64866a989d35d1f9fd903", size = 1769293, upload-time = "2026-06-01T19:37:18.067Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/2e5f1b525d5474b12b3c60abf733a755845f3bceff21542081ada515f837/aiohttp-3.14.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:71b2604c9bfc1b115547d63a094d5244b3f02799833513a99a68aaa7b167c4cb", size = 1732363, upload-time = "2026-06-01T19:37:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ce/596120faa85ca7b19cd061e3f2f3be23aa8f11a0aedf9191db9e0da1bd76/aiohttp-3.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:610d68800435903e303ca0542b9d3e4eb72a12ff33a6d471a070c1d81eebd3c2", size = 1840375, upload-time = "2026-06-01T19:37:22.104Z" }, + { url = "https://files.pythonhosted.org/packages/72/3c/a7ffe05a757a4a7867643da69357ec41f506879fbd1b231d2ed90af246b2/aiohttp-3.14.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:514db9a79337068981ee2137310283a07b4b885c584991097a91a4da419bcb81", size = 1921484, upload-time = "2026-06-01T19:37:24.068Z" }, + { url = "https://files.pythonhosted.org/packages/93/fa/2c861170bbd4a491de93a69e081db1d971092569e0d593a98ef62c384dc1/aiohttp-3.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c452d17eeb95d563fc8b936f3050301dbd1d268126c4632d8b70ede9696202ee", size = 1774153, upload-time = "2026-06-01T19:37:26.256Z" }, + { url = "https://files.pythonhosted.org/packages/9d/da/1d2f5a165f47ec9b1f69d37b8b977fdc4d501aa72ffb7930db27bb9e49ea/aiohttp-3.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed94a81506e3d1bdbad5108f497a58f2a2354aedb4ca314d5326f07d1fd1ac2d", size = 1632569, upload-time = "2026-06-01T19:37:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/46/1d/7a6e295c4257252f70f69e90864fdad74b6a1293054fb3f9e65a15de6d63/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1394dce36e0f0d260ac0b555a654de19cb989f3c1b8bdd24f505314dfea18a00", size = 1740325, upload-time = "2026-06-01T19:37:30.08Z" }, + { url = "https://files.pythonhosted.org/packages/f1/7e/e1899b1ca3ec62f1eab2a5cbde14039b97493f7f53eb88d9b668562ffa8d/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d1467d1e7b48a73ca7237e0ee4335f3d02b923dbc27b82fd254bc301c97d4026", size = 1748691, upload-time = "2026-06-01T19:37:32.211Z" }, + { url = "https://files.pythonhosted.org/packages/ec/54/4e6b61c1fe7d3433f82bcc6bd7e4d7c683a742a10c9b12a025fd3695c047/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6a5f3532125233c261cf61f32df4059cfcf482eb793c7d3db8452e3142028b86", size = 1814477, upload-time = "2026-06-01T19:37:34.173Z" }, + { url = "https://files.pythonhosted.org/packages/9c/38/86fd51be2e08d8e45c83d879d255f10391903cd9fe2a16512f7591a15873/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3ea81eb518a2ecb319d8ec6d1424a37c773f6634bd87d6985eb606b2faac419f", size = 1623393, upload-time = "2026-06-01T19:37:36.281Z" }, + { url = "https://files.pythonhosted.org/packages/78/49/466e947a42a88ee23c486d036e7e5d1b097f1bafd8084ad9c9a0a92f0f43/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:32e735c3182de7b64f6941a4ede48b38c7f47d9437bd615dd30b5bda8fa1bc93", size = 1824097, upload-time = "2026-06-01T19:37:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/f3/89/35f3410bc284682338a1be6b6ea0c5abfa05f063942cfaa9256608440434/aiohttp-3.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c21ca9a1c63d4509158f478aeb9d02914dcc52adc68d1bc9dee2452284ee5996", size = 1764790, upload-time = "2026-06-01T19:37:40.755Z" }, + { url = "https://files.pythonhosted.org/packages/42/80/2d4291bd5724d3d17e5951aff5a3e02281483fb47295f0788276ee66cd73/aiohttp-3.14.0-cp311-cp311-win32.whl", hash = "sha256:19ca5fc84130675ba11c6ca5c7da5cb65f7bf8a32cdd2b616bf49cd334688aae", size = 454176, upload-time = "2026-06-01T19:37:42.837Z" }, + { url = "https://files.pythonhosted.org/packages/59/ed/41d0ad4f6ececffc32bdf1f7b494e5498f7ca5c849ea2e3cc9bbd1668251/aiohttp-3.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:d488e6e9d3bb8ba5ae7066d5be885ae9670eba021b8c6ccb9a3a568e6b19d6e5", size = 479334, upload-time = "2026-06-01T19:37:44.776Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/c0b5e305c770053f8c3d069bb52b8196917ba91949d1962d52eb307fb0d2/aiohttp-3.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:8b93618102caf12801638a01a2b478a55410ddd71bd41cfaf6f707953a49ac43", size = 450262, upload-time = "2026-06-01T19:37:46.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/97/2b6889bfb6b6847520d50d95eb8c4307a45e28aaca39faf4a9454b3d1b2f/aiohttp-3.14.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b29518c9c2ec7e373e68259206a137c7f4f5439c58baaec4b5ab3ab799850a4e", size = 750194, upload-time = "2026-06-01T19:37:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/21/e2/62634b7fff918ed98c3c6b2f0e70d520f7f28846cb412d451b04354c6459/aiohttp-3.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbec68ce61b64cb73cab4d33df9433427b1713c8bcccb181dce695c1b6f8e87c", size = 506966, upload-time = "2026-06-01T19:37:50.014Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/5ce075150828c797a5106f1c2fb26034e709d4289b9d2bf8b07f1e59fac6/aiohttp-3.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cdf534aa455593e589302990c5097aa5c92c06c4262a20da22934f9186a5fff", size = 507527, upload-time = "2026-06-01T19:37:51.96Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/405a0ae4e6b081754a3609c1c97c63a950e000a2def16046f1e736933a0e/aiohttp-3.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb6c657104393b5fbff01a5f59b2023db74058a8077d94475d6c25d03882a108", size = 1762420, upload-time = "2026-06-01T19:37:53.839Z" }, + { url = "https://files.pythonhosted.org/packages/ae/1d/e05a7c896b15a6bc6fb8fc5319eb437861c2c49c34559ef928add6590315/aiohttp-3.14.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:46fbbec4e4fab7428d4396a3823f9320e4560aa3113b89eeebce712c27c9ed5a", size = 1733672, upload-time = "2026-06-01T19:37:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/cc/22/a72f7c459e195fa41bf4f7abd1f925b91fe91f8097e51c654229ba144a33/aiohttp-3.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c2c7e05dd5335b298085abf45ddf98673934c3ee1c083d0b9ea13d4186ad500", size = 1805064, upload-time = "2026-06-01T19:37:57.931Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/e85bdaba0be59ca4838005ebfef4048fcdd5f35a02b07057a9a123394440/aiohttp-3.14.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c7139100fbaae76515b73051d8f0aa3a3ff02e415eec8a8eee8e2223d9ba955", size = 1902125, upload-time = "2026-06-01T19:38:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/51de5c6b971c27bb1ef620293b8d1ca611ec78736b34b3f6ccf68e4c8785/aiohttp-3.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78d6f9286a629ce52728430afe18f8ed2b6c39a1fddb3802d7244b9983910ad2", size = 1783112, upload-time = "2026-06-01T19:38:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b4402bfde77e43dfb1b6ccff83c7b7ab63ed06b50c4754f0c5423fb374fe/aiohttp-3.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3c3e12cdaeb92d7dcf13db00e9f6b1956b910e47256e696df1cfa946d02159", size = 1586356, upload-time = "2026-06-01T19:38:04.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/05/750a3265ca4dc54a460bd0cb1121a8f2ce9171fce4a135fb47ea7fd594d2/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4d6a998191f5ebe3b8c28463ff72bc030250008b3193c402464efadd08b5ca02", size = 1723119, upload-time = "2026-06-01T19:38:06.713Z" }, + { url = "https://files.pythonhosted.org/packages/37/01/8c0812c50b3b1b1c37b323bf170d6be8847a8f234060485b7d1e71953f60/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0fc2b75ae8d169d853be2862d960be8550da6c5c65711d5476407eb3fdb006bd", size = 1757216, upload-time = "2026-06-01T19:38:08.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/2a/50fb98028a26887cbe48dcc1df92a90825615bc73b5584301304090cded8/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:16eee56bcc72d04600bc56c1759982c2385ec0b41d3fd3521f836bf64a0957ef", size = 1770500, upload-time = "2026-06-01T19:38:11.111Z" }, + { url = "https://files.pythonhosted.org/packages/bd/32/0ffd598a2fa2b9a423daf242e700cfdabda35d6e602394ad9ae58972c1c7/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5a2e7ca615c3ddc15b82687e05a624e5f5cba3f1d6c20cb81172d70ea498451e", size = 1576224, upload-time = "2026-06-01T19:38:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f9/b9fc381dd9b66afb33f2634c40e229d106467be0afcabe79648631ab6712/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0b7b8bbbec3ce9467ee0ebe334622fd90624f593edd3136c567811453fc4fae", size = 1794252, upload-time = "2026-06-01T19:38:15.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fb/05d9214c975f23225a8cd5c439325e338c7c377b315480ef3871db51f54e/aiohttp-3.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ba10966d4f03dd96a14365be4b8e37c327c76f11c3ca867116966cdd9f98066", size = 1760193, upload-time = "2026-06-01T19:38:17.624Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/02992fc4fb9e1b6673ee3f888a8e587a6447afda1f6f4aca776c148c2876/aiohttp-3.14.0-cp312-cp312-win32.whl", hash = "sha256:101df7779c80c0636014a6b2c6642acd3efb5b355d48347c9d7dfb720aee9430", size = 448650, upload-time = "2026-06-01T19:38:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/246532214c3abda518477cbaaf16d420295ad8effa5233844cbb38f299ab/aiohttp-3.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:b0a5747586d4467efd1f932710b269131c9717a872dce082cd92a00c1c13123a", size = 476145, upload-time = "2026-06-01T19:38:21.505Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c3/63f8c20090048915711598b0adf475b149216d736157961de06480a45b15/aiohttp-3.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:5f1c5be60add78fabb4aacd13c5a348ae79d2fcbfc7fa78da8f1eb192273b370", size = 444250, upload-time = "2026-06-01T19:38:24.027Z" }, + { url = "https://files.pythonhosted.org/packages/21/61/d11f7d9a3144bffe825247d6367cd93053666da50b94707c9129c78868d5/aiohttp-3.14.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:25400d710641a8040bf022a8a99f579e581ffa1c5bd42c33255d7d6f3957c127", size = 502399, upload-time = "2026-06-01T19:38:25.955Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9b/a7e317625d36356844f8bb022cabd305b541f968856cc3c2e0b58e53ee6e/aiohttp-3.14.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:c5492b9929826e07cc3fcb9739ae87aab05dff6b5e67a9b73fd1700c6d008981", size = 510068, upload-time = "2026-06-01T19:38:27.828Z" }, + { url = "https://files.pythonhosted.org/packages/11/41/cc2d2cfbfbdc3126ba258f3cd27d1ac8a33492ae3c35a4583ee21f0ba7f1/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3366751d68d237c621264233a32f3078bbc21b7904ab90a77e03d21390c742c6", size = 481670, upload-time = "2026-06-01T19:38:29.836Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/381f4023c3b08cb616e520f566d8c58957abad54e56441d41fe67cfb0195/aiohttp-3.14.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:57ea07d28695a7a40304d42251892a8df765e5588c10ee32afeddcd5df33c0a2", size = 487591, upload-time = "2026-06-01T19:38:31.704Z" }, + { url = "https://files.pythonhosted.org/packages/fb/4d/4506fdb7a022bdf70011a3bbb4ca00c5c570026ef6a3c5bd7bc70c39089c/aiohttp-3.14.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:076cb014191ae2e65d949e1ad01f1dcfe33e32789b5172510f3e79c79fc04d50", size = 496503, upload-time = "2026-06-01T19:38:33.6Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/c814111e04894a45d9e2defc94443879a6f118d9633d5fedfe6e2e8af5f0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2f3fc37054564dee64a855b5b092d87ec35dcddfaabf7dacb1c8a2b1f83dc0a9", size = 745870, upload-time = "2026-06-01T19:38:36.013Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ee/80eee0efddfe187e7cd05027086b7ce1c0e492e82a4eda58f5c5543a44a0/aiohttp-3.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8fcaef74d2ab0f607d7ff85a0d15e21bb5a258c4a58df1908396eb50d7f4ed3c", size = 505588, upload-time = "2026-06-01T19:38:38.282Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f8/0f28f04eef75d52fc9c715dde7ce9c0abb810fd20cfeb0fea7afd2ab1e98/aiohttp-3.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4c01b0bfc6209590960e68eac083cd22d5d87c21f974dd6208cafa5d3542bc8", size = 504492, upload-time = "2026-06-01T19:38:40.611Z" }, + { url = "https://files.pythonhosted.org/packages/ff/db/44c755232085545065c94378dfce38641b1aee647f4939fcd32f5b32e719/aiohttp-3.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f12eb7896e81caf403a2b18c9406426f1207361e7239c057ab29c076d4257e83", size = 1752111, upload-time = "2026-06-01T19:38:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6a/42e030a46743841414402a3b00cd3d78419055e86c66fb5822c14b5abfc6/aiohttp-3.14.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c79a044cacf360ec46738d863d2f41c9300d2a06ef4a7402ea0df306a350e61", size = 1729674, upload-time = "2026-06-01T19:38:44.79Z" }, + { url = "https://files.pythonhosted.org/packages/34/26/3199beb415202e3108e7b83ecebe10914d806d33fb9860c3e4aa60a19be3/aiohttp-3.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85e0675f47be4eff0636bf88c02140ea89168ae0df3ff1f3f464e9de9610d277", size = 1798808, upload-time = "2026-06-01T19:38:47.01Z" }, + { url = "https://files.pythonhosted.org/packages/bd/94/b9b6fcf0ee17c21d0d19fb8c22bf83ad18f82e702a9c3bd901a868f5e446/aiohttp-3.14.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b33e751cab03fdc960095b1e326cb5a03f5ee577d6ded59f3d1c100f8668882", size = 1891921, upload-time = "2026-06-01T19:38:49.233Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a3/3800dbd095cb2bb165a7ea5d94d790914677e27f45638c7d80e3f34c8945/aiohttp-3.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26d9224c6dd7f5c749aba4f61315a894601448b28d94d12f4dea0903e26d2096", size = 1777241, upload-time = "2026-06-01T19:38:52.04Z" }, + { url = "https://files.pythonhosted.org/packages/21/2a/45be91ad1b860508557448d4cc2e165a2ee68dd865657b73bf66cc5a00fb/aiohttp-3.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6281aecdf2732940f4fe06bd6adec5ae4d59b78b080b8e3a6b81467301010988", size = 1579554, upload-time = "2026-06-01T19:38:54.508Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/dc94df99ed1511fdf28314f722643ed334112643cab00223577085e788c4/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23e8314e7aed8576fbe33314d218bd81447a3adbc91dc36f1163bf583cd3084c", size = 1714864, upload-time = "2026-06-01T19:38:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e4/1f1c8acbb3acd5c8f795473b92c9c3d44eb60a5692c6104256c8a1c83a0c/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3b54fbff46127aeafdd764cecd0d99fa2f24a0e37ea5c18a7c3a4ac450df1db3", size = 1749803, upload-time = "2026-06-01T19:38:59.367Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c8/c45ea6e7ed84cebba939b9c334498a045ba19d79c61b0110df5f21580de3/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b27d89af91a555f58e08e4902dbcbc48862fd40095720ca705990476bd93b7ac", size = 1765023, upload-time = "2026-06-01T19:39:01.651Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a1/a932941784432962fe390e1066823aaef64b4e5ac9fa595df57b5fe472a9/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:25d2326a4967bf705a9f9913a13005e93b6020ad8a9f6bd6bd78850d5171332e", size = 1571671, upload-time = "2026-06-01T19:39:04.044Z" }, + { url = "https://files.pythonhosted.org/packages/b0/01/e1280feac522597a4d46eb67a0cdfa053cfae263033030b761ab146f29fb/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a1d209375c503472b3c0a340cdf3c55fcd82e84b46dda7caeaced59faba373ec", size = 1789904, upload-time = "2026-06-01T19:39:06.294Z" }, + { url = "https://files.pythonhosted.org/packages/fa/10/ab28818262f4d26bdb47ed5f1fc7999b69e2fc6e0370b02d0f49011f45ea/aiohttp-3.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:666c7c5036df57b693026398b69b41874a1931ac5b3485fd910e57bfac253869", size = 1754516, upload-time = "2026-06-01T19:39:08.788Z" }, + { url = "https://files.pythonhosted.org/packages/af/cc/c122eabd7a1b7e0c9bbdd6be60e4715905b858399145d9df872bb94f1427/aiohttp-3.14.0-cp313-cp313-win32.whl", hash = "sha256:23f094a1ef64823fd35854ddf5c7a80a078162f37f9d2f7c6142b51a6affa456", size = 448656, upload-time = "2026-06-01T19:39:11.171Z" }, + { url = "https://files.pythonhosted.org/packages/41/a5/bab07d79848a00eedd8ed979ccb302aaea3ac6eb9fa16bd0ed87135869b4/aiohttp-3.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e03abdaa17d553f17e1d1d06bb266b3970106c78051d06795723e748d8e49d11", size = 475803, upload-time = "2026-06-01T19:39:13.439Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/f03ade8566c153666a3871afccbedf6d99911da006325e1fc6cf72a2de99/aiohttp-3.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:acdb400538cf4769543548bb5d1eb23d39bed4f96554a6078cb728c7cb2c268b", size = 443889, upload-time = "2026-06-01T19:39:15.945Z" }, + { url = "https://files.pythonhosted.org/packages/28/03/5f36ab196a88ba5e9648ae5643e6531e67a3a8c0e96f9c6510ff41540fec/aiohttp-3.14.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:363ef9e91014e7891679bfb2ac0a7c6ea93435dbbfd10ecf41b9f06fcf506c5f", size = 503330, upload-time = "2026-06-01T19:39:18.195Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ce/8b49ec2f30f68e02f314f4832186cd45e583360a5a386058be36855d23b6/aiohttp-3.14.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:884a4edbdad77be9d0ef36142c8b504351b170df0bf62b51e784fadabf311c42", size = 509822, upload-time = "2026-06-01T19:39:20.396Z" }, + { url = "https://files.pythonhosted.org/packages/1a/fe/6edbf5d39bf29322b6816365b17ed8ede4dace164a3aea1abcd30110eb78/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:70ea956f6cc4a37620966b56c2e205d88ca3e6d85ec063277e414b1035cddad3", size = 483329, upload-time = "2026-06-01T19:39:22.607Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5a/fae531bdbc6456fb6241f46b7b81e4d8a0dd3fc09118a0055dc7141ac1ec/aiohttp-3.14.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:ea3b9806c89f61da22fddf1f12dd524fb368e5e28f1261fbdafe5c3cd8ce893b", size = 489502, upload-time = "2026-06-01T19:39:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/36/f4/48a7b0414db7fed77a03d5dde34508c026afd83510ab6bca08c313855776/aiohttp-3.14.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a071be341c2bd9b0188e62d173509f024e0a35b1c342c53c50f8daaeda8c3bd8", size = 497357, upload-time = "2026-06-01T19:39:27.197Z" }, + { url = "https://files.pythonhosted.org/packages/75/75/e85a13a370acc007fca5feb1fd1b88ac2d8426e6dadd625479b7cadd55a3/aiohttp-3.14.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:198cfe61bf253b19da1fb3e0fa122249dc4f14c12709493fed8054aa0411cc76", size = 750898, upload-time = "2026-06-01T19:39:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/3d637f800c724eff0e2bed64df72557444482366fd0a35b0cec0e6968f6c/aiohttp-3.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc203d6ce6b9106d54e2a93f41dfdfebfbca2d99962ba503bfd3e5921a6549e", size = 506986, upload-time = "2026-06-01T19:39:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/1d/df/35161f3598bf7501d2b2a805b41ab4f45a2e34150c421bcb4ef8c0d281a7/aiohttp-3.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e19d17ab02bf16832a2c8c0d55a486792c5b1645665652ee9531aebcc30cb72", size = 508033, upload-time = "2026-06-01T19:39:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/e5/39/b36e5d3d31e850fb4691dd3e941684ac490a2559249f6fa634b6b0fdf020/aiohttp-3.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d925fba0c14d5b498a8028b0107beebdfd16c5d48d702ff54f879cb017aaaca3", size = 1746213, upload-time = "2026-06-01T19:39:36.654Z" }, + { url = "https://files.pythonhosted.org/packages/b1/28/24e1409e605a9aa5d84abe0e2acb365354b70ae56d40948101cabe3341ab/aiohttp-3.14.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d33e61021222ce7f9792bcac870d6f58d8adfceda33ab857b01264f4560f2c5f", size = 1705862, upload-time = "2026-06-01T19:39:38.968Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d0/e5eb3ff1daeaf644c7e36a957517672494122628e067c38b263fa04eda77/aiohttp-3.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:44eca38755d0105bb32f47d085f5dd449846a449e1245fc105889e3279dcf8e3", size = 1798909, upload-time = "2026-06-01T19:39:41.334Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ba/8943f906f0570342886ababb9a722a44e360f786a028c5e0b0e29e3f735b/aiohttp-3.14.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f13087e06f68fea4941c21a0c541c00553aa16e4f8fd7bbe2b198df761e964d6", size = 1868892, upload-time = "2026-06-01T19:39:43.807Z" }, + { url = "https://files.pythonhosted.org/packages/3a/05/27df32c844b2156e1675a8d8ec22d963e3c8ba469ed7ceb1863320c7b521/aiohttp-3.14.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ff82be7f1ef73634cb77890a770743239bc3d487b848669be1c599889336dc0a", size = 1751659, upload-time = "2026-06-01T19:39:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/7f/62/da182e5910ab912b2e88aa919b61a16046a37a95714a5795b02eb57b2d18/aiohttp-3.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a150c0875ac8fd87f1c398650841308a30d65facf7416b12dbdb9cfdcbe5a48c", size = 1578775, upload-time = "2026-06-01T19:39:48.902Z" }, + { url = "https://files.pythonhosted.org/packages/66/e3/53c67097e8a5ce98625e91e3fa7f43c9c6940de680345d03b3509a72a078/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edc01ea4e1ec5a1649a28866262bf24195889ff7b27bdd947029a6086741de9b", size = 1710090, upload-time = "2026-06-01T19:39:51.392Z" }, + { url = "https://files.pythonhosted.org/packages/dd/55/0e2732ca598c7a4dfe8a775662376d0ca2977cb1030e48386d4da5d9a456/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:540632bf882ff8fc88f2e1697be0761578e89e0d79fb4a8a6d65dc5da7e729d4", size = 1715016, upload-time = "2026-06-01T19:39:53.807Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/f0b73730798c9ca525afc30b39f1f81bbe24e245d9654c54d3b39d63212d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:860a86bc2c80237f5dff52edcf427e10a8d8352271fd84845429a3e60199e02c", size = 1763810, upload-time = "2026-06-01T19:39:56.31Z" }, + { url = "https://files.pythonhosted.org/packages/71/cc/11acb6c4518f448323405a7312b6f255d0f974a34373ad1db7633c4aadc8/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cbd50e6a50d6b99283a826b18cbdebf65b0797689a7535cb0e9dd37be0f63c3", size = 1573064, upload-time = "2026-06-01T19:39:58.718Z" }, + { url = "https://files.pythonhosted.org/packages/de/2d/28c31dde0a7dc98c0ee7d0da2ddcec3f7688c4fc131e5989e278d0c03c0a/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20144819e99db593e22bbd2f3f2691a5e149f879142d6b8670254708853ff4fb", size = 1775765, upload-time = "2026-06-01T19:40:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/b8/69/155c4ef3aec96417d47024800472b33b16c5d8a665371dcd044c2afdf25d/aiohttp-3.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26b6d79aa54cb4ed50cc7d41ed14e99e0f1fc8e7c2d42f2e05b37aea897b2b52", size = 1733716, upload-time = "2026-06-01T19:40:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/5f/44/6126116fd8a316b712bb615660b855c78466bb67ba1bb1742427eafcf7ac/aiohttp-3.14.0-cp314-cp314-win32.whl", hash = "sha256:106ed074a856f3e21d186b8579e2c8afb6da598e267cdaab01059e13db2fc44d", size = 453684, upload-time = "2026-06-01T19:40:06.277Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d7/eff4c58a88c5cac5e38b55f44fb8a6d3929c3cbd77356e383e094d3220bd/aiohttp-3.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f770846edae8f00ecc57af825bce811f787f87a7dcf0e90d191790efe5b31f7", size = 481758, upload-time = "2026-06-01T19:40:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/17b5bd9fbcb46e688f02e572f517754a9a75831e7b54702f027761dc4fa5/aiohttp-3.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:acf1581c4f21ed4b80a2dded504d87b055a071a84d5737ea966435f768275ac6", size = 450557, upload-time = "2026-06-01T19:40:11.03Z" }, + { url = "https://files.pythonhosted.org/packages/12/34/6180103ce9aabc8ebff3f7bb55a1228ffe60f61042823031d9692cb7b101/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6aa1a40f9cbb3da9f80714c5966b8946c21e6a2530d809b9498b33161e3c8733", size = 787878, upload-time = "2026-06-01T19:40:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/92/e9/08954a40e8b7baa3d8beadd2b074b186e9b1e9c8ddabc288678a6265de50/aiohttp-3.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b62af5a8cc96a194eaa01a9ed7b34a3ffa58d3d8daaa1a0d7a749353ad12d228", size = 524400, upload-time = "2026-06-01T19:40:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/08/6a/b5965a634ac4d5ba99a463314cf4ab214ca073fcdc38a15e0294273701fc/aiohttp-3.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6eb63b1417efaf7d1002a6ad034a40d44376afcc16508a57f8e74b49ad26a095", size = 527904, upload-time = "2026-06-01T19:40:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/06/b4/932bcdd850c354d9bcca30f360e475d7852e30413fbbd44b182782ed5432/aiohttp-3.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c20b9ad156a79eb97be5cf9e069eec01d2f0dc8472ffbd75299a8b2d4c2cbbde", size = 1912162, upload-time = "2026-06-01T19:40:20.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/85/ce79bab0310d2e3fd2d7bc7e44412abeff7c8338f8a21dd0f2f1714989e5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:40ae7b0642c25632c7eabc4a04754012691864d2a1b93becf7cddb76027b838a", size = 1778813, upload-time = "2026-06-01T19:40:23.726Z" }, + { url = "https://files.pythonhosted.org/packages/05/54/ba62ac2d1bc87e010aad23751e383b8794e45d931df67677313a2da78823/aiohttp-3.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95f5217e76a046b9f228a101717ef8d42b1eb3d9d196d15202db5bf41df88936", size = 1899969, upload-time = "2026-06-01T19:40:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/dc/82/7cc7907725d83a19f31551334061e1ab8e108b1d7ac52632a2a844a4acb5/aiohttp-3.14.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1a4a9f17e85b80878c176695c1998c790e83731d8271881e5d356488652a1f9e", size = 1991771, upload-time = "2026-06-01T19:40:29.061Z" }, + { url = "https://files.pythonhosted.org/packages/d0/1c/a57de71a4508c93a830b77c28af3d08cd97f606dedfc6b94275347744508/aiohttp-3.14.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:145262119b07d7f95abc1839add35ba2bfc84551d4b4660ca11542c0b215455b", size = 1868606, upload-time = "2026-06-01T19:40:31.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ae/3839726cd49150a53ed340cc24ce5ba09d4c2117020ef9d45542bec5eb2f/aiohttp-3.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:49a33ded29b0b2fa7a367a02cf0fb89af602bb87542a16177ec8ce1c9c51d12a", size = 1665437, upload-time = "2026-06-01T19:40:35.01Z" }, + { url = "https://files.pythonhosted.org/packages/35/1e/c237923232c7da7f0392ea25d89fc5e60c0e93f685f4ebca8e7bcdd5271c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cc736a9c9fc2bc4dd71fd404815741b6573df27c3f985948ec4076989ac57de", size = 1834090, upload-time = "2026-06-01T19:40:37.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/02/a5a7a2524f92d3911761b405a7c067c751891942144adc13e2ad79611e39/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4141a3e5342ee3053a9cab54d25b64ed28289c1041e4c54b3d99839314d90ce", size = 1816907, upload-time = "2026-06-01T19:40:40.46Z" }, + { url = "https://files.pythonhosted.org/packages/fa/76/a8b9f0d09234d516af9f2d7dd715557f33b5da3b0b56ead41d1170e86e3c/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e30871b2d58996cb81aac52d2b1d15ac05257131ef0f90f18c2115a380fbfe7c", size = 1840382, upload-time = "2026-06-01T19:40:43.48Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8e/140e715a0a4bbc211979ea30ec8396ad2ed5bf90ab87d8058fc4668b1923/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:667b881d083ccae3900ea5a241e17e5007ca78844c53ed389bb63d48f729d9c7", size = 1659497, upload-time = "2026-06-01T19:40:46.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/7ba5de8af9650b9767b063c675427b8685f43fa7ce563673a7bc3af60f08/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b584dfe615d151e9b8f0a8ecb3aee6147f2927ec5b95ba25fe621f5377510928", size = 1870829, upload-time = "2026-06-01T19:40:49.583Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bc/2aaab2f85cadb26ea59c091fa2b8e370d625154b5c14b478f1b489d07551/aiohttp-3.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6199707cc40e0e9cd39c36fbc97bec416c704e1d0ddce03412bb3b3e6a90ccd0", size = 1832281, upload-time = "2026-06-01T19:40:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/39/98/31b9ad9fbc01f0075ee7221002df5fd2d10b647f451ca5f30edc802d9dd6/aiohttp-3.14.0-cp314-cp314t-win32.whl", hash = "sha256:a8d93334d4961c9d566b1f046c81dee475b7c21eb730728d38237bfa70d1c8e6", size = 490597, upload-time = "2026-06-01T19:40:54.937Z" }, + { url = "https://files.pythonhosted.org/packages/59/1f/299b21441c8de42ff70fddc7cfe65e92f810abcf740739a09b56f7835364/aiohttp-3.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d2ffe9b614f50f069068b3b52e73414e4107fc10b7efc939a76acff9251fdd2", size = 525789, upload-time = "2026-06-01T19:40:57.306Z" }, + { url = "https://files.pythonhosted.org/packages/70/11/7f83fcba9ee05d4c54d61b3f8104da0d43a59adac44dd28effc0c9a10422/aiohttp-3.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7a3fc4358e65826c515350f199c210de747cf669998211b1ee6c2e46de364b24", size = 467399, upload-time = "2026-06-01T19:40:59.993Z" }, +] + +[[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 = "aiokafka" version = "0.13.0" @@ -79,6 +252,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/48/1ce3773f392f02ceda37aee168fade9d725483a9592c202d06044cd093ff/aiormq-6.9.4-py3-none-any.whl", hash = "sha256:726a8586695e863fba68cf88842065ab12348c9438dcebdfc9d0bddaf6083277", size = 32166, upload-time = "2026-03-23T09:18:17.523Z" }, ] +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -185,6 +371,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/a7/1a31561d10a089fcb46fe286766dd4e053a12f6e23b4fd1c26478aff2475/boltons-21.0.0-py2.py3-none-any.whl", hash = "sha256:b9bb7b58b2b420bbe11a6025fdef6d3e5edc9f76a42fb467afe7ca212ef9948b", size = 193723, upload-time = "2021-05-17T01:20:20.023Z" }, ] +[[package]] +name = "botocore" +version = "1.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/22/7fe08c726a2e3b11a0aef8bf177e83891c9cb2dc1809d35c9ed91a9e60e6/botocore-1.41.5.tar.gz", hash = "sha256:0367622b811597d183bfcaab4a350f0d3ede712031ce792ef183cabdee80d3bf", size = 14668152, upload-time = "2025-11-26T20:27:38.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/4e/21cd0b8f365449f1576f93de1ec8718ed18a7a3bc086dfbdeb79437bba7a/botocore-1.41.5-py3-none-any.whl", hash = "sha256:3fef7fcda30c82c27202d232cfdbd6782cb27f20f8e7e21b20606483e66ee73a", size = 14337008, upload-time = "2025-11-26T20:27:35.208Z" }, +] + +[[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 = "bracex" version = "2.6" @@ -887,6 +1099,9 @@ rabbit = [ redis = [ { name = "redis" }, ] +sqs = [ + { name = "aiobotocore" }, +] [package.dev-dependencies] dev = [ @@ -898,7 +1113,7 @@ dev = [ { name = "dirty-equals" }, { name = "email-validator" }, { name = "fastapi" }, - { name = "faststream", extra = ["cli", "confluent", "kafka", "mqtt", "nats", "otel", "prometheus", "rabbit", "redis"] }, + { name = "faststream", extra = ["cli", "confluent", "kafka", "mqtt", "nats", "otel", "prometheus", "rabbit", "redis", "sqs"] }, { name = "freezegun" }, { name = "griffe" }, { name = "httpx" }, @@ -931,6 +1146,7 @@ dev = [ { name = "ruff" }, { name = "semgrep" }, { name = "setuptools" }, + { name = "types-aiobotocore", extra = ["sqs"] }, { name = "types-aiofiles" }, { name = "types-deprecated" }, { name = "types-docutils" }, @@ -964,6 +1180,7 @@ lint = [ { name = "mypy" }, { name = "ruff" }, { name = "semgrep" }, + { name = "types-aiobotocore", extra = ["sqs"] }, { name = "types-aiofiles" }, { name = "types-deprecated" }, { name = "types-docutils" }, @@ -974,7 +1191,7 @@ lint = [ { name = "zizmor" }, ] optionals = [ - { name = "faststream", extra = ["cli", "confluent", "kafka", "mqtt", "nats", "otel", "prometheus", "rabbit", "redis"] }, + { name = "faststream", extra = ["cli", "confluent", "kafka", "mqtt", "nats", "otel", "prometheus", "rabbit", "redis", "sqs"] }, ] test-core = [ { name = "covdefaults" }, @@ -1014,6 +1231,7 @@ testing = [ [package.metadata] requires-dist = [ { name = "aio-pika", marker = "extra == 'rabbit'", specifier = ">=9,<10" }, + { name = "aiobotocore", marker = "extra == 'sqs'", specifier = ">=2.13,<3" }, { name = "aiokafka", marker = "extra == 'kafka'", specifier = ">=0.9,<0.15" }, { name = "anyio", specifier = ">=4.0,<5" }, { name = "confluent-kafka", marker = "python_full_version >= '3.13' and extra == 'confluent'", specifier = ">=2.6,!=2.8.1,<3" }, @@ -1028,7 +1246,7 @@ requires-dist = [ { name = "watchfiles", marker = "extra == 'cli'", specifier = ">=0.15.0,<1.2.0" }, { name = "zmqtt", marker = "extra == 'mqtt'", specifier = ">=0.0.4" }, ] -provides-extras = ["rabbit", "kafka", "confluent", "nats", "redis", "mqtt", "cli", "otel", "prometheus"] +provides-extras = ["rabbit", "kafka", "confluent", "nats", "redis", "mqtt", "cli", "otel", "prometheus", "sqs"] [package.metadata.requires-dev] dev = [ @@ -1040,7 +1258,7 @@ dev = [ { name = "dirty-equals", specifier = "==0.11" }, { name = "email-validator", specifier = "==2.3.0" }, { name = "fastapi", specifier = "==0.128.0" }, - { name = "faststream", extras = ["rabbit", "kafka", "confluent", "nats", "redis", "mqtt", "otel", "cli", "prometheus"] }, + { name = "faststream", extras = ["rabbit", "kafka", "confluent", "nats", "redis", "mqtt", "sqs", "otel", "cli", "prometheus"] }, { name = "freezegun", specifier = ">=1.5.5" }, { name = "griffe", specifier = "==1.15.0" }, { name = "httpx", specifier = "==0.28.1" }, @@ -1073,6 +1291,7 @@ dev = [ { name = "ruff", specifier = "==0.14.14" }, { name = "semgrep", specifier = "==1.150.0" }, { name = "setuptools", specifier = ">=80.9.0" }, + { name = "types-aiobotocore", extras = ["sqs"], specifier = ">=3.7.0" }, { name = "types-aiofiles" }, { name = "types-deprecated" }, { name = "types-docutils" }, @@ -1106,6 +1325,7 @@ lint = [ { name = "mypy", specifier = "==1.19.1" }, { name = "ruff", specifier = "==0.14.14" }, { name = "semgrep", specifier = "==1.150.0" }, + { name = "types-aiobotocore", extras = ["sqs"], specifier = ">=3.7.0" }, { name = "types-aiofiles" }, { name = "types-deprecated" }, { name = "types-docutils" }, @@ -1115,7 +1335,7 @@ lint = [ { name = "types-setuptools" }, { name = "zizmor", specifier = "==1.22.0" }, ] -optionals = [{ name = "faststream", extras = ["rabbit", "kafka", "confluent", "nats", "redis", "mqtt", "otel", "cli", "prometheus"] }] +optionals = [{ name = "faststream", extras = ["rabbit", "kafka", "confluent", "nats", "redis", "mqtt", "sqs", "otel", "cli", "prometheus"] }] test-core = [ { name = "covdefaults", specifier = ">=2.3.0" }, { name = "dirty-equals", specifier = "==0.11" }, @@ -1172,6 +1392,127 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "ghp-import" version = "2.1.0" @@ -1378,6 +1719,15 @@ 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 = "jsmin" version = "3.0.1" @@ -3600,6 +3950,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, ] +[[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] +sqs = [ + { name = "types-aiobotocore-sqs" }, +] + +[[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-aiofiles" version = "25.1.0.20251011" @@ -3609,6 +3989,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/0f/76917bab27e270bb6c32addd5968d69e558e5b6f7fb4ac4cbfa282996a96/types_aiofiles-25.1.0.20251011-py3-none-any.whl", hash = "sha256:8ff8de7f9d42739d8f0dadcceeb781ce27cd8d8c4152d4a7c52f6b20edb8149c", size = 14338, upload-time = "2025-10-11T02:44:50.054Z" }, ] +[[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 = "types-cffi" version = "2.0.0.20260402" From 65bc19d9d266e4cb1625a5e435a96a316ff750ae Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Fri, 5 Jun 2026 19:50:37 +0300 Subject: [PATCH 02/19] chore(docker): add localstack service for SQS testing Add LocalStack to docker-compose.yaml and documentation to enable local AWS service emulation, specifically for testing the new SQS broker integration. Includes a healthcheck to ensure SQS availability. --- docker-compose.yaml | 13 +++++++++++++ docs/includes/docker-compose.yaml | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/docker-compose.yaml b/docker-compose.yaml index 90064b4d23c..dd95bbe1055 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -80,6 +80,19 @@ services: security_opt: - no-new-privileges:true + localstack: + image: localstack/localstack:4 + ports: + - "4566:4566" + healthcheck: + test: 'curl -s http://localhost:4566/_localstack/health | grep -q ''"sqs": "available"'' || exit 1' + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + security_opt: + - no-new-privileges:true + faststream: build: . volumes: diff --git a/docs/includes/docker-compose.yaml b/docs/includes/docker-compose.yaml index eaa48911b34..c1fdbecaaf9 100644 --- a/docs/includes/docker-compose.yaml +++ b/docs/includes/docker-compose.yaml @@ -79,3 +79,10 @@ services: - "1883:1883" security_opt: - no-new-privileges:true + + localstack: + image: localstack/localstack:4 + ports: + - "4566:4566" + security_opt: + - no-new-privileges:true From 0d9de86dac4ddfe9a28a20dbc1b86718105fef38 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sat, 6 Jun 2026 23:47:18 +0300 Subject: [PATCH 03/19] feat(sqs): refactor broker configuration and improve SQS support Refactor the SQS implementation to use a centralized configuration module, improve queue declaration logic, and enhance testing coverage. - Move `SQSBrokerConfig` to `faststream.sqs.configs` - Update `SQSSubscriber` to support automatic queue declaration via `SQSQueue` - Implement prefix support in `SQSPublisher` - Update `docker-compose.yaml` to rename `localstack` service to `sqs` - Add comprehensive test suite for SQS brokers, including: - Consumption and publishing - Router and middleware integration - Request/Response patterns - Test client functionality - Fix `SQSRegistrator` to raise `SetupError` instead of `TypeError` --- docker-compose.yaml | 2 +- docs/includes/docker-compose.yaml | 2 +- faststream/sqs/__init__.py | 20 +++++-- faststream/sqs/broker/broker.py | 2 +- faststream/sqs/broker/registrator.py | 7 ++- faststream/sqs/broker/router.py | 2 +- faststream/sqs/configs/__init__.py | 3 + .../{broker/config.py => configs/broker.py} | 13 +++-- faststream/sqs/publisher/config.py | 2 +- faststream/sqs/publisher/factory.py | 8 +-- faststream/sqs/publisher/specification.py | 2 +- faststream/sqs/publisher/usecase.py | 8 ++- faststream/sqs/schemas/queue.py | 3 + faststream/sqs/subscriber/config.py | 6 +- faststream/sqs/subscriber/factory.py | 8 +-- faststream/sqs/subscriber/specification.py | 2 +- faststream/sqs/subscriber/usecase.py | 6 +- tests/brokers/sqs/basic.py | 15 ++++- tests/brokers/sqs/test_consume.py | 11 ++++ tests/brokers/sqs/test_include_router.py | 18 ++++++ tests/brokers/sqs/test_middlewares.py | 26 +++++++++ tests/brokers/sqs/test_publish.py | 11 ++++ tests/brokers/sqs/test_requests.py | 34 +++++++++++ tests/brokers/sqs/test_router.py | 20 +++++++ ...test_testclient.py => test_test_client.py} | 56 +++++++++++++------ 25 files changed, 234 insertions(+), 53 deletions(-) create mode 100644 faststream/sqs/configs/__init__.py rename faststream/sqs/{broker/config.py => configs/broker.py} (82%) create mode 100644 tests/brokers/sqs/test_consume.py create mode 100644 tests/brokers/sqs/test_include_router.py create mode 100644 tests/brokers/sqs/test_middlewares.py create mode 100644 tests/brokers/sqs/test_publish.py create mode 100644 tests/brokers/sqs/test_requests.py create mode 100644 tests/brokers/sqs/test_router.py rename tests/brokers/sqs/{test_testclient.py => test_test_client.py} (60%) diff --git a/docker-compose.yaml b/docker-compose.yaml index dd95bbe1055..b288b95470c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -80,7 +80,7 @@ services: security_opt: - no-new-privileges:true - localstack: + sqs: image: localstack/localstack:4 ports: - "4566:4566" diff --git a/docs/includes/docker-compose.yaml b/docs/includes/docker-compose.yaml index c1fdbecaaf9..bdb1224092b 100644 --- a/docs/includes/docker-compose.yaml +++ b/docs/includes/docker-compose.yaml @@ -80,7 +80,7 @@ services: security_opt: - no-new-privileges:true - localstack: + sqs: image: localstack/localstack:4 ports: - "4566:4566" diff --git a/faststream/sqs/__init__.py b/faststream/sqs/__init__.py index aada24ae6d2..9a735128c40 100644 --- a/faststream/sqs/__init__.py +++ b/faststream/sqs/__init__.py @@ -1,15 +1,20 @@ +from faststream._internal.parser import ParserProto +from faststream._internal.testing.app import TestApp + +SQSParserType = ParserProto["MessageTypeDef"] # type: ignore[name-defined] + try: - from faststream.sqs.annotations import SQSMessage - from faststream.sqs.broker.broker import SQSBroker - from faststream.sqs.broker.router import SQSPublisher, SQSRoute, SQSRouter - from faststream.sqs.response import SQSResponse - from faststream.sqs.schemas import ( + from .annotations import SQSMessage + from .broker.broker import SQSBroker + from .broker.router import SQSPublisher, SQSRoute, SQSRouter + from .response import SQSPublishCommand, SQSResponse + from .schemas import ( FifoQueue, RedriveAllowPolicy, RedrivePolicy, SQSQueue, ) - from faststream.sqs.testing import TestSQSBroker + from .testing import TestSQSBroker except ImportError as e: if "aiobotocore" not in e.msg and "botocore" not in e.msg: @@ -25,10 +30,13 @@ "RedrivePolicy", "SQSBroker", "SQSMessage", + "SQSParserType", + "SQSPublishCommand", "SQSPublisher", "SQSQueue", "SQSResponse", "SQSRoute", "SQSRouter", + "TestApp", "TestSQSBroker", ) diff --git a/faststream/sqs/broker/broker.py b/faststream/sqs/broker/broker.py index 7311c699f3c..7be443ceaff 100644 --- a/faststream/sqs/broker/broker.py +++ b/faststream/sqs/broker/broker.py @@ -16,7 +16,7 @@ from faststream.middlewares import AckPolicy from faststream.response.publish_type import PublishType from faststream.specification.schema import BrokerSpec -from faststream.sqs.broker.config import SQSBrokerConfig +from faststream.sqs.configs import SQSBrokerConfig from faststream.sqs.message import SQSRawMessage from faststream.sqs.publisher.producer import SQSFastProducer from faststream.sqs.response import SQSBatchPublishCommand, SQSPublishCommand diff --git a/faststream/sqs/broker/registrator.py b/faststream/sqs/broker/registrator.py index d9d045bfa2c..8eb0f09065b 100644 --- a/faststream/sqs/broker/registrator.py +++ b/faststream/sqs/broker/registrator.py @@ -5,8 +5,9 @@ from faststream._internal.broker.registrator import Registrator from faststream._internal.constants import EMPTY +from faststream.exceptions import SetupError from faststream.middlewares import AckPolicy -from faststream.sqs.broker.config import SQSBrokerConfig +from faststream.sqs.configs import SQSBrokerConfig from faststream.sqs.message import SQSRawMessage from faststream.sqs.publisher.factory import create_publisher from faststream.sqs.subscriber.factory import create_subscriber @@ -121,7 +122,7 @@ def publisher( # type: ignore[override] group_id=group_id, deduplication_id=deduplication_id, delay_seconds=delay_seconds, - broker_config=cast("SQSBrokerConfig", self.config), + config=cast("SQSBrokerConfig", self.config), title_=title, description_=description, schema_=schema, @@ -145,7 +146,7 @@ def include_router( f"Router must be an instance of SQSRegistrator, " f"got {type(router).__name__} instead." ) - raise TypeError(msg) + raise SetupError(msg) super().include_router( router, diff --git a/faststream/sqs/broker/router.py b/faststream/sqs/broker/router.py index ca06fa06d0b..9980ffea509 100644 --- a/faststream/sqs/broker/router.py +++ b/faststream/sqs/broker/router.py @@ -8,7 +8,7 @@ ) from faststream._internal.constants import EMPTY from faststream.middlewares import AckPolicy -from faststream.sqs.broker.config import SQSBrokerConfig +from faststream.sqs.configs import SQSBrokerConfig from .registrator import SQSRegistrator diff --git a/faststream/sqs/configs/__init__.py b/faststream/sqs/configs/__init__.py new file mode 100644 index 00000000000..f811d340554 --- /dev/null +++ b/faststream/sqs/configs/__init__.py @@ -0,0 +1,3 @@ +from .broker import SQSBrokerConfig + +__all__ = ("SQSBrokerConfig",) diff --git a/faststream/sqs/broker/config.py b/faststream/sqs/configs/broker.py similarity index 82% rename from faststream/sqs/broker/config.py rename to faststream/sqs/configs/broker.py index 05ea723595a..8e6448a9b14 100644 --- a/faststream/sqs/broker/config.py +++ b/faststream/sqs/configs/broker.py @@ -48,12 +48,17 @@ async def get_queue_url(self, queue: str) -> str: self._queue_urls[queue] = url return url - async def declare_queue(self, queue: "SQSQueue") -> str: - """Create the queue if needed and cache its URL.""" + async def declare_queue(self, queue: "SQSQueue", *, name: str | None = None) -> str: + """Create the queue if needed and cache its URL. + + ``name`` overrides ``queue.queue_name`` so a router prefix can be applied + without mutating the queue object. + """ + queue_name = name or queue.queue_name resp = await self.client.create_queue( - QueueName=queue.queue_name, + QueueName=queue_name, Attributes=queue.to_attributes(), # type: ignore[arg-type] ) url = resp["QueueUrl"] - self._queue_urls[queue.queue_name] = url + self._queue_urls[queue_name] = url return url diff --git a/faststream/sqs/publisher/config.py b/faststream/sqs/publisher/config.py index 19a227d5bf1..01f7caaaae7 100644 --- a/faststream/sqs/publisher/config.py +++ b/faststream/sqs/publisher/config.py @@ -4,7 +4,7 @@ PublisherSpecificationConfig, PublisherUsecaseConfig, ) -from faststream.sqs.broker.config import SQSBrokerConfig +from faststream.sqs.configs import SQSBrokerConfig @dataclass(kw_only=True) diff --git a/faststream/sqs/publisher/factory.py b/faststream/sqs/publisher/factory.py index 6771d95edf8..b63cc1aa461 100644 --- a/faststream/sqs/publisher/factory.py +++ b/faststream/sqs/publisher/factory.py @@ -7,7 +7,7 @@ from .usecase import SQSPublisher if TYPE_CHECKING: - from faststream.sqs.broker.config import SQSBrokerConfig + from faststream.sqs.configs import SQSBrokerConfig def create_publisher( @@ -17,7 +17,7 @@ def create_publisher( group_id: str | None, deduplication_id: str | None, delay_seconds: int, - broker_config: "SQSBrokerConfig", + config: "SQSBrokerConfig", # AsyncAPI args schema_: Any | None, title_: str | None, @@ -32,11 +32,11 @@ def create_publisher( group_id=group_id, deduplication_id=deduplication_id, delay_seconds=delay_seconds, - _outer_config=broker_config, + _outer_config=config, ) specification = SQSPublisherSpecification( - _outer_config=broker_config, + _outer_config=config, specification_config=SQSPublisherSpecificationConfig( queue=queue_name, schema_=schema_, diff --git a/faststream/sqs/publisher/specification.py b/faststream/sqs/publisher/specification.py index 2b7dc4fee6c..0aa4d9098fe 100644 --- a/faststream/sqs/publisher/specification.py +++ b/faststream/sqs/publisher/specification.py @@ -6,7 +6,7 @@ OperationBinding, sqs as sqs_bindings, ) -from faststream.sqs.broker.config import SQSBrokerConfig +from faststream.sqs.configs import SQSBrokerConfig from .config import SQSPublisherSpecificationConfig diff --git a/faststream/sqs/publisher/usecase.py b/faststream/sqs/publisher/usecase.py index d9f819d7144..88c1b6cb620 100644 --- a/faststream/sqs/publisher/usecase.py +++ b/faststream/sqs/publisher/usecase.py @@ -13,7 +13,7 @@ from faststream._internal.endpoint.publisher import PublisherSpecification from faststream._internal.types import PublisherMiddleware from faststream.response.response import PublishCommand - from faststream.sqs.broker.config import SQSBrokerConfig + from faststream.sqs.configs import SQSBrokerConfig from .config import SQSPublisherConfig @@ -30,12 +30,16 @@ def __init__( ) -> None: super().__init__(config, specification) - self.queue = config.queue + self._queue = config.queue self.headers = config.headers or {} self.group_id = config.group_id self.deduplication_id = config.deduplication_id self.delay_seconds = config.delay_seconds + @property + def queue(self) -> str: + return f"{self._outer_config.prefix}{self._queue}" + @override async def publish( self, diff --git a/faststream/sqs/schemas/queue.py b/faststream/sqs/schemas/queue.py index 10f9f850ea9..2695eeb1ff9 100644 --- a/faststream/sqs/schemas/queue.py +++ b/faststream/sqs/schemas/queue.py @@ -41,6 +41,9 @@ class SQSQueue(BaseModel): name: str # whether this is a FIFO queue; excluded from the attributes dump fifo: bool = Field(default=False, exclude=True) + # whether to create the queue automatically or just connect to it; + # excluded from the attributes dump + declare: bool = Field(default=True, exclude=True) delay_seconds: int | None = Field(default=None, alias="DelaySeconds", ge=0, le=900) maximum_message_size: int | None = Field( diff --git a/faststream/sqs/subscriber/config.py b/faststream/sqs/subscriber/config.py index 2cd78cc951b..a5a2a253d2a 100644 --- a/faststream/sqs/subscriber/config.py +++ b/faststream/sqs/subscriber/config.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING from faststream._internal.configs import ( SubscriberSpecificationConfig, @@ -7,7 +7,7 @@ ) from faststream._internal.constants import EMPTY from faststream.middlewares.acknowledgement.config import AckPolicy -from faststream.sqs.broker.config import SQSBrokerConfig +from faststream.sqs.configs import SQSBrokerConfig if TYPE_CHECKING: from faststream.sqs.schemas import SQSQueue @@ -23,7 +23,7 @@ class SQSSubscriberConfig(SubscriberUsecaseConfig): _outer_config: "SQSBrokerConfig" = field(default_factory=SQSBrokerConfig) queue: str - declare: Optional["SQSQueue"] = None + declare: "SQSQueue" wait_time_seconds: int = 5 max_messages: int = 10 visibility_timeout: int | None = None diff --git a/faststream/sqs/subscriber/factory.py b/faststream/sqs/subscriber/factory.py index 367fffe8458..7327d3d8dcb 100644 --- a/faststream/sqs/subscriber/factory.py +++ b/faststream/sqs/subscriber/factory.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from faststream.middlewares import AckPolicy - from faststream.sqs.broker.config import SQSBrokerConfig + from faststream.sqs.configs import SQSBrokerConfig def create_subscriber( @@ -27,12 +27,12 @@ def create_subscriber( description_: str | None = None, include_in_schema: bool = True, ) -> SQSSubscriber: - declare: SQSQueue | None = queue if isinstance(queue, SQSQueue) else None - queue_name = queue.queue_name if isinstance(queue, SQSQueue) else queue + queue_obj = queue if isinstance(queue, SQSQueue) else SQSQueue(name=queue) + queue_name = queue_obj.queue_name subscriber_config = SQSSubscriberConfig( queue=queue_name, - declare=declare, + declare=queue_obj, wait_time_seconds=wait_time_seconds, max_messages=max_messages, visibility_timeout=visibility_timeout, diff --git a/faststream/sqs/subscriber/specification.py b/faststream/sqs/subscriber/specification.py index a88273d3cd4..1b3522cad6d 100644 --- a/faststream/sqs/subscriber/specification.py +++ b/faststream/sqs/subscriber/specification.py @@ -8,7 +8,7 @@ OperationBinding, sqs as sqs_bindings, ) -from faststream.sqs.broker.config import SQSBrokerConfig +from faststream.sqs.configs import SQSBrokerConfig from .config import SQSSubscriberSpecificationConfig diff --git a/faststream/sqs/subscriber/usecase.py b/faststream/sqs/subscriber/usecase.py index b3df351e7e8..150fc1c171e 100644 --- a/faststream/sqs/subscriber/usecase.py +++ b/faststream/sqs/subscriber/usecase.py @@ -17,7 +17,7 @@ from faststream._internal.endpoint.subscriber import SubscriberSpecification from faststream._internal.endpoint.subscriber.call_item import CallsCollection from faststream.message import StreamMessage - from faststream.sqs.broker.config import SQSBrokerConfig + from faststream.sqs.configs import SQSBrokerConfig from faststream.sqs.message import SQSMessage, SQSRawMessage from .config import SQSSubscriberConfig @@ -78,8 +78,8 @@ def get_log_context( return self.build_log_context(message=message, queue=self.queue) async def _resolve_queue_url(self) -> str: - if self._declare is not None: - return await self._outer_config.declare_queue(self._declare) + if self._declare.declare: + return await self._outer_config.declare_queue(self._declare, name=self.queue) return await self._outer_config.get_queue_url(self.queue) @override diff --git a/tests/brokers/sqs/basic.py b/tests/brokers/sqs/basic.py index 6d7a46ad36d..b82365573a3 100644 --- a/tests/brokers/sqs/basic.py +++ b/tests/brokers/sqs/basic.py @@ -1,3 +1,4 @@ +import os from typing import Any from faststream.sqs.broker.broker import SQSBroker @@ -7,12 +8,24 @@ class SQSTestcaseConfig(BaseTestcaseConfig): + # SQS is poll-based (long-polling), so round-trips are slower than the + # push-based brokers the default 3s timeout was tuned for. + timeout: float = 15.0 + def get_broker( self, apply_types: bool = False, **kwargs: Any, ) -> SQSBroker: - return SQSBroker(region_name="us-east-1", apply_types=apply_types, **kwargs) + # LocalStack defaults; override via env for real AWS / other emulators. + return SQSBroker( + endpoint_url=os.environ.get("SQS_ENDPOINT_URL", "http://localhost:4566"), + region_name=os.environ.get("AWS_REGION", "us-east-1"), + aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID", "test"), + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY", "test"), + apply_types=apply_types, + **kwargs, + ) def get_router(self, **kwargs: Any) -> SQSRouter: return SQSRouter(**kwargs) diff --git a/tests/brokers/sqs/test_consume.py b/tests/brokers/sqs/test_consume.py new file mode 100644 index 00000000000..1a0f1360c06 --- /dev/null +++ b/tests/brokers/sqs/test_consume.py @@ -0,0 +1,11 @@ +import pytest + +from tests.brokers.base.consume import BrokerRealConsumeTestcase + +from .basic import SQSTestcaseConfig + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestConsume(SQSTestcaseConfig, BrokerRealConsumeTestcase): + pass diff --git a/tests/brokers/sqs/test_include_router.py b/tests/brokers/sqs/test_include_router.py new file mode 100644 index 00000000000..9324d0967f7 --- /dev/null +++ b/tests/brokers/sqs/test_include_router.py @@ -0,0 +1,18 @@ +import pytest + +from tests.brokers.base.include_router import ( + IncludePublisherTestcase, + IncludeSubscriberTestcase, +) + +from .basic import SQSTestcaseConfig + + +@pytest.mark.sqs() +class TestSubscriber(SQSTestcaseConfig, IncludeSubscriberTestcase): + pass + + +@pytest.mark.sqs() +class TestPublisher(SQSTestcaseConfig, IncludePublisherTestcase): + pass diff --git a/tests/brokers/sqs/test_middlewares.py b/tests/brokers/sqs/test_middlewares.py new file mode 100644 index 00000000000..de67ba2f0ec --- /dev/null +++ b/tests/brokers/sqs/test_middlewares.py @@ -0,0 +1,26 @@ +import pytest + +from tests.brokers.base.middlewares import ( + ExceptionMiddlewareTestcase, + MiddlewareTestcase, + MiddlewaresOrderTestcase, +) + +from .basic import SQSMemoryTestcaseConfig, SQSTestcaseConfig + + +@pytest.mark.sqs() +class TestMiddlewaresOrder(SQSMemoryTestcaseConfig, MiddlewaresOrderTestcase): + pass + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestMiddlewares(SQSTestcaseConfig, MiddlewareTestcase): + pass + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestExceptionMiddlewares(SQSTestcaseConfig, ExceptionMiddlewareTestcase): + pass diff --git a/tests/brokers/sqs/test_publish.py b/tests/brokers/sqs/test_publish.py new file mode 100644 index 00000000000..ee48dc3493f --- /dev/null +++ b/tests/brokers/sqs/test_publish.py @@ -0,0 +1,11 @@ +import pytest + +from tests.brokers.base.publish import BrokerPublishTestcase + +from .basic import SQSTestcaseConfig + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestPublish(SQSTestcaseConfig, BrokerPublishTestcase): + pass diff --git a/tests/brokers/sqs/test_requests.py b/tests/brokers/sqs/test_requests.py new file mode 100644 index 00000000000..452df9e0cea --- /dev/null +++ b/tests/brokers/sqs/test_requests.py @@ -0,0 +1,34 @@ +from typing import Any + +import pytest + +from faststream import BaseMiddleware +from tests.brokers.base.requests import RequestsTestcase + +from .basic import SQSMemoryTestcaseConfig, SQSTestcaseConfig + + +class Mid(BaseMiddleware): + async def on_receive(self) -> None: + self.msg["Body"] *= 2 + + async def consume_scope(self, call_next, msg): + msg.body *= 2 + return await call_next(msg) + + +@pytest.mark.asyncio() +class SQSRequestsTestcase(RequestsTestcase): + def get_middleware(self, **kwargs: Any): + return Mid + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestRealRequests(SQSTestcaseConfig, SQSRequestsTestcase): + pass + + +@pytest.mark.sqs() +class TestRequestTestClient(SQSMemoryTestcaseConfig, SQSRequestsTestcase): + pass diff --git a/tests/brokers/sqs/test_router.py b/tests/brokers/sqs/test_router.py new file mode 100644 index 00000000000..4f19dbbb79b --- /dev/null +++ b/tests/brokers/sqs/test_router.py @@ -0,0 +1,20 @@ +import pytest + +from faststream.sqs import SQSPublisher, SQSRoute +from tests.brokers.base.router import RouterLocalTestcase, RouterTestcase + +from .basic import SQSMemoryTestcaseConfig, SQSTestcaseConfig + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestRouter(SQSTestcaseConfig, RouterTestcase): + route_class = SQSRoute + publisher_class = SQSPublisher + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestRouterLocal(SQSMemoryTestcaseConfig, RouterLocalTestcase): + route_class = SQSRoute + publisher_class = SQSPublisher diff --git a/tests/brokers/sqs/test_testclient.py b/tests/brokers/sqs/test_test_client.py similarity index 60% rename from tests/brokers/sqs/test_testclient.py rename to tests/brokers/sqs/test_test_client.py index 20d6f5f9abc..7e0eff3b3df 100644 --- a/tests/brokers/sqs/test_testclient.py +++ b/tests/brokers/sqs/test_test_client.py @@ -1,27 +1,51 @@ import pytest from faststream import FastStream -from faststream.sqs import FifoQueue, SQSBroker, SQSRoute, SQSRouter, TestSQSBroker +from faststream.sqs import FifoQueue, SQSRoute, SQSRouter +from faststream.sqs.testing import FakeProducer +from tests.brokers.base.testclient import BrokerTestclientTestcase from tests.marks import require_sqs +from .basic import SQSMemoryTestcaseConfig + @require_sqs @pytest.mark.sqs() @pytest.mark.asyncio() -class TestSQSTestClient: +class TestTestclient(SQSMemoryTestcaseConfig, BrokerTestclientTestcase): + def get_fake_producer_class(self) -> type: + return FakeProducer + + @pytest.mark.connected() + async def test_broker_gets_patched_attrs_within_cm(self) -> None: + await super().test_broker_gets_patched_attrs_within_cm(FakeProducer) + + @pytest.mark.connected() + async def test_broker_with_real_doesnt_get_patched(self) -> None: + await super().test_broker_with_real_doesnt_get_patched() + + @pytest.mark.connected() + async def test_broker_with_real_patches_publishers_and_subscribers( + self, + queue: str, + ) -> None: + await super().test_broker_with_real_patches_publishers_and_subscribers(queue) + + # SQS-specific behaviour + async def test_pub_sub(self, queue: str) -> None: - broker = SQSBroker() + broker = self.get_broker() @broker.subscriber(queue) async def handler(msg: str) -> str: return msg + "-ok" - async with TestSQSBroker(broker) as br: + async with self.patch_broker(broker) as br: await br.publish("hello", queue) handler.mock.assert_called_once_with("hello") async def test_publisher_decorator(self, queue: str) -> None: - broker = SQSBroker() + broker = self.get_broker() out = queue + "-out" @broker.subscriber(out) @@ -32,24 +56,24 @@ async def downstream(msg: str) -> None: ... async def handler(msg: str) -> str: return msg.upper() - async with TestSQSBroker(broker) as br: + async with self.patch_broker(broker) as br: await br.publish("abc", queue) handler.mock.assert_called_once_with("abc") downstream.mock.assert_called_once_with("ABC") async def test_request_rpc(self, queue: str) -> None: - broker = SQSBroker(response_queue="responses") + broker = self.get_broker(response_queue="responses") @broker.subscriber(queue) async def handler(msg: str) -> str: return f"reply:{msg}" - async with TestSQSBroker(broker) as br: + async with self.patch_broker(broker) as br: response = await br.request("ping", queue) assert await response.decode() == "reply:ping" async def test_router_prefix(self, queue: str) -> None: - broker = SQSBroker() + broker = self.get_broker() received: list[str] = [] async def handler(msg: str) -> None: @@ -58,39 +82,39 @@ async def handler(msg: str) -> None: router = SQSRouter(prefix="pref-", handlers=[SQSRoute(handler, queue)]) broker.include_router(router) - async with TestSQSBroker(broker) as br: + async with self.patch_broker(broker) as br: await br.publish("data", f"pref-{queue}") assert received == ["data"] async def test_fifo_group_id(self, queue: str) -> None: - broker = SQSBroker() + broker = self.get_broker() fifo = FifoQueue(name=queue) @broker.subscriber(fifo) async def handler(msg: str) -> None: ... - async with TestSQSBroker(broker) as br: + async with self.patch_broker(broker) as br: await br.publish("m", fifo, group_id="g1") handler.mock.assert_called_once_with("m") async def test_no_match_skips_handler(self, queue: str) -> None: - broker = SQSBroker() + broker = self.get_broker() @broker.subscriber(queue) async def handler(msg: str) -> None: ... - async with TestSQSBroker(broker) as br: + async with self.patch_broker(broker) as br: await br.publish("x", queue + "-other") handler.mock.assert_not_called() async def test_app_runs(self, queue: str) -> None: - broker = SQSBroker() + broker = self.get_broker() app = FastStream(broker) @broker.subscriber(queue) async def handler(msg: str) -> None: ... - async with TestSQSBroker(broker): + async with self.patch_broker(broker): await broker.start() await broker.publish("hi", queue) handler.mock.assert_called_once_with("hi") From 50a90ce00af0ff3aa8b06f4a797089ac5d457a94 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sun, 7 Jun 2026 21:41:17 +0300 Subject: [PATCH 04/19] feat(sqs): implement batch consumption and enhanced message validation Introduce support for batch message processing, improved SQS-specific exception handling, and stricter validation for producer constraints. - Add `SQSBatchMessage` and `batch=True` support for subscribers - Implement producer guards for SQS limits (message size, attribute count, and FIFO requirements) - Add specialized SQS exceptions: `BatchSendError`, `FifoQueueError`, `MessageTooLargeError`, and `TooManyMessageAttributesError` - Enhance `SQSMessage` with system attribute access (e.g., `group_id`, `sequence_number`) - Improve `SQSParser` to handle binary attributes and empty bodies correctly - Add robust error handling and exponential backoff for response queue consumption in `SQSBroker` - Update `SQSQueue` schema to support tags and KMS configuration options --- faststream/sqs/__init__.py | 12 + faststream/sqs/broker/broker.py | 43 ++-- faststream/sqs/broker/registrator.py | 3 + faststream/sqs/configs/broker.py | 13 +- faststream/sqs/exceptions.py | 63 +++++ faststream/sqs/message.py | 90 ++++++- faststream/sqs/parser.py | 78 +++++- faststream/sqs/publisher/producer.py | 119 +++++++++- faststream/sqs/publisher/specification.py | 5 +- faststream/sqs/publisher/usecase.py | 4 +- faststream/sqs/schemas/queue.py | 6 + faststream/sqs/subscriber/config.py | 1 + faststream/sqs/subscriber/factory.py | 2 + faststream/sqs/subscriber/specification.py | 5 +- faststream/sqs/subscriber/usecase.py | 64 ++++- faststream/sqs/testing.py | 8 +- tests/brokers/sqs/test_features.py | 262 +++++++++++++++++++++ tests/brokers/sqs/test_requests.py | 14 +- uv.lock | 2 +- 19 files changed, 738 insertions(+), 56 deletions(-) create mode 100644 faststream/sqs/exceptions.py create mode 100644 tests/brokers/sqs/test_features.py diff --git a/faststream/sqs/__init__.py b/faststream/sqs/__init__.py index 9a735128c40..1e08468fd8e 100644 --- a/faststream/sqs/__init__.py +++ b/faststream/sqs/__init__.py @@ -7,6 +7,13 @@ from .annotations import SQSMessage from .broker.broker import SQSBroker from .broker.router import SQSPublisher, SQSRoute, SQSRouter + from .exceptions import ( + BatchSendError, + FifoQueueError, + MessageTooLargeError, + SQSError, + TooManyMessageAttributesError, + ) from .response import SQSPublishCommand, SQSResponse from .schemas import ( FifoQueue, @@ -25,10 +32,14 @@ raise ImportError(INSTALL_FASTSTREAM_SQS) from e __all__ = ( + "BatchSendError", "FifoQueue", + "FifoQueueError", + "MessageTooLargeError", "RedriveAllowPolicy", "RedrivePolicy", "SQSBroker", + "SQSError", "SQSMessage", "SQSParserType", "SQSPublishCommand", @@ -39,4 +50,5 @@ "SQSRouter", "TestApp", "TestSQSBroker", + "TooManyMessageAttributesError", ) diff --git a/faststream/sqs/broker/broker.py b/faststream/sqs/broker/broker.py index 7be443ceaff..c00d6b09880 100644 --- a/faststream/sqs/broker/broker.py +++ b/faststream/sqs/broker/broker.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any, Optional, Union, cast from aiobotocore.session import get_session +from botocore.exceptions import BotoCoreError, ClientError from fast_depends import Provider, dependency_provider from typing_extensions import override @@ -155,10 +156,12 @@ async def _setup_response_queue(self) -> None: if self._response_queue is None: return - if isinstance(self._response_queue, SQSQueue): - url = await self.config.declare_queue(self._response_queue) - else: - url = await self.config.get_queue_url(self._response_queue) + queue = ( + self._response_queue + if isinstance(self._response_queue, SQSQueue) + else SQSQueue(name=self._response_queue) + ) + url = await self.config.declare_queue(queue) cast("SQSFastProducer", self.config.producer).response_queue_url = url self._response_task = asyncio.create_task(self._consume_responses(url)) @@ -166,23 +169,35 @@ async def _setup_response_queue(self) -> None: async def _consume_responses(self, queue_url: str) -> None: client = self.config.client producer = cast("SQSFastProducer", self.config.producer) + backoff = 1.0 while self.running: - with suppress(Exception): + try: resp = await client.receive_message( QueueUrl=queue_url, MaxNumberOfMessages=10, WaitTimeSeconds=5, MessageAttributeNames=["All"], ) - for message in resp.get("Messages", []): - attrs = message.get("MessageAttributes", {}) or {} - cid = attrs.get("correlation_id", {}).get("StringValue", "") - if cid: - producer.resolve_response(cid, message) - await client.delete_message( - QueueUrl=queue_url, - ReceiptHandle=message["ReceiptHandle"], - ) + except (ClientError, BotoCoreError) as e: + # Don't busy-loop silently on e.g. bad credentials; log and back off. + self.config.logger.log( + f"SQS response-queue receive failed, retrying in {backoff:.0f}s: {e}", + logging.WARNING, + ) + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 30.0) + continue + + backoff = 1.0 + for message in resp.get("Messages", []): + attrs = message.get("MessageAttributes", {}) or {} + cid = attrs.get("correlation_id", {}).get("StringValue", "") + if cid: + producer.resolve_response(cid, message) + await client.delete_message( + QueueUrl=queue_url, + ReceiptHandle=message["ReceiptHandle"], + ) @override async def start(self) -> None: diff --git a/faststream/sqs/broker/registrator.py b/faststream/sqs/broker/registrator.py index 8eb0f09065b..11b9760da9d 100644 --- a/faststream/sqs/broker/registrator.py +++ b/faststream/sqs/broker/registrator.py @@ -33,6 +33,7 @@ def subscriber( # type: ignore[override] wait_time_seconds: int = 5, max_messages: int = 10, visibility_timeout: int | None = None, + batch: bool = False, # broker arguments ack_policy: AckPolicy = EMPTY, no_reply: bool = False, @@ -53,6 +54,7 @@ def subscriber( # type: ignore[override] wait_time_seconds: Long-poll wait time (0-20). max_messages: Max messages per receive (1-10). visibility_timeout: Per-receive visibility timeout override. + batch: Consume up to ``max_messages`` at once; the handler receives a list. ack_policy: Acknowledgement policy for message processing. no_reply: Whether to disable FastStream RPC / reply-to responses. dependencies: Dependencies list to apply to the subscriber. @@ -69,6 +71,7 @@ def subscriber( # type: ignore[override] wait_time_seconds=wait_time_seconds, max_messages=max_messages, visibility_timeout=visibility_timeout, + batch=batch, ack_policy=ack_policy, no_reply=no_reply, config=cast("SQSBrokerConfig", self.config), diff --git a/faststream/sqs/configs/broker.py b/faststream/sqs/configs/broker.py index 8e6448a9b14..e4e2e897cab 100644 --- a/faststream/sqs/configs/broker.py +++ b/faststream/sqs/configs/broker.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from faststream._internal.configs import BrokerConfig from faststream._internal.parser import DefaultCodec @@ -55,10 +55,13 @@ async def declare_queue(self, queue: "SQSQueue", *, name: str | None = None) -> without mutating the queue object. """ queue_name = name or queue.queue_name - resp = await self.client.create_queue( - QueueName=queue_name, - Attributes=queue.to_attributes(), # type: ignore[arg-type] - ) + create_kwargs: dict[str, Any] = { + "QueueName": queue_name, + "Attributes": queue.to_attributes(), + } + if queue.tags: + create_kwargs["tags"] = queue.tags + resp = await self.client.create_queue(**create_kwargs) url = resp["QueueUrl"] self._queue_urls[queue_name] = url return url diff --git a/faststream/sqs/exceptions.py b/faststream/sqs/exceptions.py new file mode 100644 index 00000000000..06cf50721f7 --- /dev/null +++ b/faststream/sqs/exceptions.py @@ -0,0 +1,63 @@ +from typing import Any + +from faststream.exceptions import FastStreamException + +# AWS SQS hard limits (https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html) +MAX_BATCH_ENTRIES = 10 +MAX_MESSAGE_ATTRIBUTES = 10 +MAX_MESSAGE_SIZE = 262_144 # 256 KiB, shared by body + attributes + + +class SQSError(FastStreamException): + """Base class for SQS-specific FastStream errors.""" + + +class MessageTooLargeError(SQSError, ValueError): + """Raised when a message body (with attributes) exceeds the 256 KiB SQS limit.""" + + def __init__(self, size: int) -> None: + self.size = size + + def __str__(self) -> str: + return ( + f"Message size {self.size} bytes exceeds the SQS limit of " + f"{MAX_MESSAGE_SIZE} bytes (256 KiB)." + ) + + +class TooManyMessageAttributesError(SQSError, ValueError): + """Raised when a message carries more than 10 ``MessageAttributes``. + + SQS reserves a maximum of 10 message attributes per message. FastStream + transports ``content-type``/``reply_to``/``correlation_id`` as attributes, + so a few slots are always consumed by transport metadata. + """ + + def __init__(self, count: int) -> None: + self.count = count + + def __str__(self) -> str: + return ( + f"A message can carry at most {MAX_MESSAGE_ATTRIBUTES} MessageAttributes, " + f"got {self.count} (including FastStream transport attributes " + "content-type/reply_to/correlation_id). Reduce the number of headers." + ) + + +class FifoQueueError(SQSError, ValueError): + """Raised on invalid FIFO publish parameters (missing group/deduplication id).""" + + +class BatchSendError(SQSError): + """Raised when ``send_message_batch`` reports failed entries. + + ``failed`` holds the raw ``Failed`` records returned by SQS so callers can + inspect per-message error codes. + """ + + def __init__(self, failed: list[dict[str, Any]]) -> None: + self.failed = failed + + def __str__(self) -> str: + ids = ", ".join(f.get("Id", "?") for f in self.failed) + return f"{len(self.failed)} message(s) failed to send in batch: ids={ids}" diff --git a/faststream/sqs/message.py b/faststream/sqs/message.py index ff346fb3f5e..a9518c0fa0a 100644 --- a/faststream/sqs/message.py +++ b/faststream/sqs/message.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast from faststream.message import StreamMessage @@ -24,11 +24,37 @@ class SQSMessage(StreamMessage["SQSRawMessage"]): # Set by SQSParser at parse time so ack/nack/reject can reach the queue. sqs_client: "SQSClient | None" = None queue_url: str = "" + # SQS system attributes (Attributes), set by SQSParser at parse time. + system_attributes: dict[str, str] = {} # noqa: RUF012 @property def receipt_handle(self) -> str: return self.raw_message.get("ReceiptHandle", "") + @property + def approximate_receive_count(self) -> int: + """How many times SQS has delivered this message (1 on first receive). + + Useful for poison-message detection; pair it with a queue ``RedrivePolicy`` + so exhausted messages move to a dead-letter queue. + """ + return int(self.system_attributes.get("ApproximateReceiveCount", 0)) + + @property + def sent_timestamp(self) -> int | None: + value = self.system_attributes.get("SentTimestamp") + return int(value) if value is not None else None + + @property + def group_id(self) -> str | None: + """``MessageGroupId`` for FIFO queues.""" + return self.system_attributes.get("MessageGroupId") + + @property + def sequence_number(self) -> str | None: + """``SequenceNumber`` assigned by a FIFO queue.""" + return self.system_attributes.get("SequenceNumber") + async def ack(self) -> None: if self.committed is None and self.sqs_client is not None and self.receipt_handle: await self.sqs_client.delete_message( @@ -37,12 +63,18 @@ async def ack(self) -> None: ) await super().ack() - async def nack(self) -> None: + async def nack(self, visibility_timeout: int | None = None) -> None: + """Return the message for redelivery via ``ChangeMessageVisibility``. + + ``visibility_timeout=0`` (default) redelivers immediately. Pass a larger + value for a backoff before the message becomes visible again — the SQS + analogue of NATS' ``nack(delay=...)``. + """ if self.committed is None and self.sqs_client is not None and self.receipt_handle: await self.sqs_client.change_message_visibility( QueueUrl=self.queue_url, ReceiptHandle=self.receipt_handle, - VisibilityTimeout=0, + VisibilityTimeout=visibility_timeout or 0, ) await super().nack() @@ -53,3 +85,55 @@ async def reject(self) -> None: ReceiptHandle=self.receipt_handle, ) await super().reject() + + +class SQSBatchMessage(SQSMessage): + """A batch of SQS messages handed to a ``batch=True`` subscriber. + + ``raw_message`` is the list of raw SQS messages; ack/nack/reject act on all + of them at once using the SQS batch APIs (chunked to the 10-entry limit). + """ + + @property + def receipt_handles(self) -> list[str]: + messages = cast("list[Any]", self.raw_message) + return [m.get("ReceiptHandle", "") for m in messages if m.get("ReceiptHandle")] + + @staticmethod + def _chunked(handles: list[str], size: int = 10) -> "list[list[str]]": + return [handles[i : i + size] for i in range(0, len(handles), size)] + + async def _delete_all(self) -> None: + if self.sqs_client is None: + return + for chunk in self._chunked(self.receipt_handles): + await self.sqs_client.delete_message_batch( + QueueUrl=self.queue_url, + Entries=[{"Id": str(i), "ReceiptHandle": h} for i, h in enumerate(chunk)], + ) + + async def ack(self) -> None: + if self.committed is None and self.sqs_client is not None: + await self._delete_all() + await super(SQSMessage, self).ack() + + async def nack(self, visibility_timeout: int | None = None) -> None: + if self.committed is None and self.sqs_client is not None: + for chunk in self._chunked(self.receipt_handles): + await self.sqs_client.change_message_visibility_batch( + QueueUrl=self.queue_url, + Entries=[ + { + "Id": str(i), + "ReceiptHandle": h, + "VisibilityTimeout": visibility_timeout or 0, + } + for i, h in enumerate(chunk) + ], + ) + await super(SQSMessage, self).nack() + + async def reject(self) -> None: + if self.committed is None and self.sqs_client is not None: + await self._delete_all() + await super(SQSMessage, self).reject() diff --git a/faststream/sqs/parser.py b/faststream/sqs/parser.py index f17a42373b0..31b7d6b535d 100644 --- a/faststream/sqs/parser.py +++ b/faststream/sqs/parser.py @@ -1,8 +1,8 @@ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from faststream.message import StreamMessage, decode_message -from .message import SQSMessage +from .message import SQSBatchMessage, SQSMessage if TYPE_CHECKING: from types_aiobotocore_sqs import SQSClient @@ -12,8 +12,13 @@ from .message import SQSRawMessage +# SQS forbids an empty ``MessageBody``; we send a placeholder and flag it with +# this reserved attribute so the parser can restore the original empty body. +EMPTY_BODY_ATTR = "empty-body" +EMPTY_BODY_PLACEHOLDER = " " + # Message attribute names FastStream reserves for transport metadata. -RESERVED_ATTRS = ("content-type", "reply_to", "correlation_id") +RESERVED_ATTRS = ("content-type", "reply_to", "correlation_id", EMPTY_BODY_ATTR) class SQSParser: @@ -31,6 +36,18 @@ def bind(self, client: "SQSClient", queue_url: str) -> None: self.client = client self.queue_url = queue_url + @staticmethod + def _attr_value(attr: dict[str, Any]) -> Any: + """Decode a single ``MessageAttribute`` honouring its ``DataType``. + + SQS attributes are typed String/Number/Binary. Binary values arrive as + raw bytes; String/Number arrive as strings (Number is a numeric string). + """ + data_type = str(attr.get("DataType", "String")) + if data_type.startswith("Binary"): + return attr.get("BinaryValue", b"") + return attr.get("StringValue", "") + async def parse_message(self, message: "SQSRawMessage") -> SQSMessage: attributes: dict[str, Any] = message.get("MessageAttributes", {}) or {} @@ -38,20 +55,30 @@ async def parse_message(self, message: "SQSRawMessage") -> SQSMessage: content_type: str | None = None reply_to: str = "" correlation_id: str | None = None + empty_body = False for name, attr in attributes.items(): - value = attr.get("StringValue", "") + value = self._attr_value(attr) if name == "content-type": content_type = value elif name == "reply_to": reply_to = value elif name == "correlation_id": correlation_id = value + elif name == EMPTY_BODY_ATTR: + empty_body = True else: headers[name] = value body = message.get("Body", "") raw_body = body.encode() if isinstance(body, str) else (body or b"") + if empty_body: + raw_body = b"" + + # SQS system attributes (ApproximateReceiveCount, MessageGroupId, ...) + system_attributes = cast( + "dict[str, str]", message.get("Attributes", {}) or {} + ) parsed = SQSMessage( raw_message=message, @@ -64,7 +91,50 @@ async def parse_message(self, message: "SQSRawMessage") -> SQSMessage: ) parsed.sqs_client = self.client parsed.queue_url = self.queue_url + parsed.system_attributes = system_attributes return parsed async def decode_message(self, msg: "StreamMessage[Any]") -> "DecodedMessage": return decode_message(msg) + + async def parse_batch( + self, + messages: list["SQSRawMessage"], + ) -> SQSBatchMessage: + """Parse a batch of raw SQS messages into a single ``SQSBatchMessage``. + + The message body is the list of raw bodies; transport metadata is taken + from the first message, matching the batch convention of other brokers. + """ + bodies: list[Any] = [] + batch_headers: list[dict[str, Any]] = [] + content_type: str | None = None + reply_to = "" + correlation_id: str | None = None + + for idx, message in enumerate(messages): + single = await self.parse_message(message) + bodies.append(single.body) + batch_headers.append(single.headers) + if idx == 0: + content_type = single.content_type + reply_to = single.reply_to + correlation_id = single.correlation_id + + first = messages[0] if messages else {} + parsed = SQSBatchMessage( + raw_message=cast("Any", messages), + body=bodies, + headers=batch_headers[0] if batch_headers else {}, + batch_headers=batch_headers, + content_type=content_type, + reply_to=reply_to, + correlation_id=correlation_id, + message_id=first.get("MessageId"), + ) + parsed.sqs_client = self.client + parsed.queue_url = self.queue_url + return parsed + + async def decode_batch(self, msg: "StreamMessage[Any]") -> "DecodedMessage": + return [decode_message(await self.parse_message(m)) for m in msg.raw_message] diff --git a/faststream/sqs/publisher/producer.py b/faststream/sqs/publisher/producer.py index 4ff3b8dedd5..4cf0dea7174 100644 --- a/faststream/sqs/publisher/producer.py +++ b/faststream/sqs/publisher/producer.py @@ -1,5 +1,6 @@ import asyncio -from typing import TYPE_CHECKING, Any, Optional +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any, Optional, cast import anyio from typing_extensions import override @@ -9,7 +10,16 @@ from faststream._internal.producer import ProducerProto from faststream.exceptions import FeatureNotSupportedException, IncorrectState from faststream.message import gen_cor_id -from faststream.sqs.parser import SQSParser +from faststream.sqs.exceptions import ( + MAX_BATCH_ENTRIES, + MAX_MESSAGE_ATTRIBUTES, + MAX_MESSAGE_SIZE, + BatchSendError, + FifoQueueError, + MessageTooLargeError, + TooManyMessageAttributesError, +) +from faststream.sqs.parser import EMPTY_BODY_ATTR, EMPTY_BODY_PLACEHOLDER, SQSParser from faststream.sqs.response import SQSPublishCommand if TYPE_CHECKING: @@ -81,7 +91,16 @@ async def get_queue_url(self, queue: str) -> str: return queue if queue in self._queue_urls: return self._queue_urls[queue] - resp = await self._connected_client.get_queue_url(QueueName=queue) + client = self._connected_client + try: + resp = await client.get_queue_url(QueueName=queue) + except client.exceptions.QueueDoesNotExist: + # auto-create on publish, mirroring topic auto-creation in other brokers + attributes = {"FifoQueue": "true"} if queue.endswith(".fifo") else {} + resp = await client.create_queue( + QueueName=queue, + Attributes=attributes, # type: ignore[arg-type] + ) url = resp["QueueUrl"] self._queue_urls[queue] = url return url @@ -105,14 +124,59 @@ def _build_message_attributes( } return attrs + @staticmethod + def _attributes_size(attrs: dict[str, Any]) -> int: + """Approximate the bytes SQS counts for ``MessageAttributes``. + + SQS counts attribute names, data types and values against the 256 KiB + message-size limit. This is a close upper bound, not the exact wire size. + """ + total = 0 + for name, attr in attrs.items(): + total += len(name.encode()) + total += len(str(attr.get("DataType", "")).encode()) + value = attr.get("StringValue", attr.get("BinaryValue", "")) + if isinstance(value, (bytes, bytearray)): + total += len(value) + else: + total += len(str(value).encode()) + return total + + def _validate_message(self, body: str, attrs: dict[str, Any]) -> None: + if len(attrs) > MAX_MESSAGE_ATTRIBUTES: + raise TooManyMessageAttributesError(len(attrs)) + + size = len(body.encode()) + self._attributes_size(attrs) + if size > MAX_MESSAGE_SIZE: + raise MessageTooLargeError(size) + + @staticmethod + def _validate_fifo(cmd: "SQSPublishCommand") -> None: + """FIFO queues require a ``MessageGroupId`` on every message.""" + if cmd.queue.endswith(".fifo") and not cmd.group_id: + msg = ( + f"FIFO queue '{cmd.queue}' requires a `group_id` (MessageGroupId). " + "Pass `group_id=...` to publish()/the publisher." + ) + raise FifoQueueError(msg) + async def _build_send_kwargs(self, cmd: "SQSPublishCommand") -> dict[str, Any]: + self._validate_fifo(cmd) payload, content_type = await self.codec.encode(cmd.body, self.serializer) body = payload.decode() if isinstance(payload, bytes) else str(payload) + attrs = self._build_message_attributes(cmd, content_type) + + # SQS rejects empty bodies; send a placeholder and flag it for the parser. + if not body: + body = EMPTY_BODY_PLACEHOLDER + attrs[EMPTY_BODY_ATTR] = {"DataType": "String", "StringValue": "1"} + + self._validate_message(body, attrs) kwargs: dict[str, Any] = { "QueueUrl": await self.get_queue_url(cmd.queue), "MessageBody": body, - "MessageAttributes": self._build_message_attributes(cmd, content_type), + "MessageAttributes": attrs, } if cmd.delay_seconds: kwargs["DelaySeconds"] = cmd.delay_seconds @@ -129,15 +193,22 @@ async def publish(self, cmd: "SQSPublishCommand") -> Any: @override async def publish_batch(self, cmd: "SQSPublishCommand") -> Any: + self._validate_fifo(cmd) url = await self.get_queue_url(cmd.queue) + entries: list[dict[str, Any]] = [] for idx, body in enumerate(cmd.batch_bodies): payload, content_type = await self.codec.encode(body, self.serializer) text = payload.decode() if isinstance(payload, bytes) else str(payload) + attrs = self._build_message_attributes(cmd, content_type) + if not text: + text = EMPTY_BODY_PLACEHOLDER + attrs[EMPTY_BODY_ATTR] = {"DataType": "String", "StringValue": "1"} + self._validate_message(text, attrs) entry: dict[str, Any] = { "Id": str(idx), "MessageBody": text, - "MessageAttributes": self._build_message_attributes(cmd, content_type), + "MessageAttributes": attrs, } if cmd.group_id: entry["MessageGroupId"] = cmd.group_id @@ -145,10 +216,40 @@ async def publish_batch(self, cmd: "SQSPublishCommand") -> Any: entry["MessageDeduplicationId"] = f"{cmd.deduplication_id}-{idx}" entries.append(entry) - return await self._connected_client.send_message_batch( - QueueUrl=url, - Entries=entries, # type: ignore[arg-type] - ) + # SQS caps a batch at 10 entries and 256 KiB total; split into chunks. + successful: list[dict[str, Any]] = [] + failed: list[dict[str, Any]] = [] + for chunk in self._chunk_entries(entries): + resp = await self._connected_client.send_message_batch( + QueueUrl=url, + Entries=chunk, # type: ignore[arg-type] + ) + successful.extend(cast("list[dict[str, Any]]", resp.get("Successful", []))) + failed.extend(cast("list[dict[str, Any]]", resp.get("Failed", []))) + + if failed: + raise BatchSendError(failed) + + return {"Successful": successful, "Failed": failed} + + @staticmethod + def _chunk_entries(entries: list[dict[str, Any]]) -> Iterator[list[dict[str, Any]]]: + """Yield batches of at most 10 entries, each under the 256 KiB limit.""" + chunk: list[dict[str, Any]] = [] + chunk_size = 0 + for entry in entries: + entry_size = len(entry["MessageBody"].encode()) + ( + SQSFastProducer._attributes_size(entry.get("MessageAttributes", {})) + ) + too_many = len(chunk) >= MAX_BATCH_ENTRIES + too_big = chunk and (chunk_size + entry_size) > MAX_MESSAGE_SIZE + if too_many or too_big: + yield chunk + chunk, chunk_size = [], 0 + chunk.append(entry) + chunk_size += entry_size + if chunk: + yield chunk @override async def request(self, cmd: "SQSPublishCommand") -> Any: diff --git a/faststream/sqs/publisher/specification.py b/faststream/sqs/publisher/specification.py index 0aa4d9098fe..e84507c74b8 100644 --- a/faststream/sqs/publisher/specification.py +++ b/faststream/sqs/publisher/specification.py @@ -41,7 +41,10 @@ def get_schema(self) -> dict[str, PublisherSpec]: ), bindings=ChannelBinding( sqs=sqs_bindings.ChannelBinding( - queue={"name": self.queue}, + queue={ + "name": self.queue, + "fifo": self.queue.endswith(".fifo"), + }, ), ), ), diff --git a/faststream/sqs/publisher/usecase.py b/faststream/sqs/publisher/usecase.py index 88c1b6cb620..c3770bc5a0a 100644 --- a/faststream/sqs/publisher/usecase.py +++ b/faststream/sqs/publisher/usecase.py @@ -59,7 +59,9 @@ async def publish( correlation_id=correlation_id or gen_cor_id(), group_id=group_id or self.group_id, deduplication_id=deduplication_id or self.deduplication_id, - delay_seconds=delay_seconds if delay_seconds is not None else self.delay_seconds, + delay_seconds=delay_seconds + if delay_seconds is not None + else self.delay_seconds, _publish_type=PublishType.PUBLISH, ) return await self._basic_publish( diff --git a/faststream/sqs/schemas/queue.py b/faststream/sqs/schemas/queue.py index 2695eeb1ff9..778732acce7 100644 --- a/faststream/sqs/schemas/queue.py +++ b/faststream/sqs/schemas/queue.py @@ -63,10 +63,16 @@ class SQSQueue(BaseModel): default=None, alias="RedriveAllowPolicy" ) kms_master_key_id: str | None = Field(default=None, alias="KmsMasterKeyId") + kms_data_key_reuse_period_seconds: int | None = Field( + default=None, alias="KmsDataKeyReusePeriodSeconds", ge=60, le=86400 + ) sqs_managed_sse_enabled: bool | None = Field( default=None, alias="SqsManagedSseEnabled" ) + # Cost-allocation tags; passed to ``create_queue(tags=...)``, not an attribute. + tags: dict[str, str] = Field(default_factory=dict, exclude=True) + model_config = {"populate_by_name": True} @property diff --git a/faststream/sqs/subscriber/config.py b/faststream/sqs/subscriber/config.py index a5a2a253d2a..baf829c006b 100644 --- a/faststream/sqs/subscriber/config.py +++ b/faststream/sqs/subscriber/config.py @@ -27,6 +27,7 @@ class SQSSubscriberConfig(SubscriberUsecaseConfig): wait_time_seconds: int = 5 max_messages: int = 10 visibility_timeout: int | None = None + batch: bool = False @property def ack_policy(self) -> AckPolicy: diff --git a/faststream/sqs/subscriber/factory.py b/faststream/sqs/subscriber/factory.py index 7327d3d8dcb..a677f0da4b1 100644 --- a/faststream/sqs/subscriber/factory.py +++ b/faststream/sqs/subscriber/factory.py @@ -18,6 +18,7 @@ def create_subscriber( wait_time_seconds: int, max_messages: int, visibility_timeout: int | None, + batch: bool, # Subscriber args ack_policy: "AckPolicy", no_reply: bool, @@ -36,6 +37,7 @@ def create_subscriber( wait_time_seconds=wait_time_seconds, max_messages=max_messages, visibility_timeout=visibility_timeout, + batch=batch, no_reply=no_reply, _outer_config=config, _ack_policy=ack_policy, diff --git a/faststream/sqs/subscriber/specification.py b/faststream/sqs/subscriber/specification.py index 1b3522cad6d..f1974fc2268 100644 --- a/faststream/sqs/subscriber/specification.py +++ b/faststream/sqs/subscriber/specification.py @@ -54,7 +54,10 @@ def get_schema(self) -> dict[str, SubscriberSpec]: ), bindings=ChannelBinding( sqs=sqs_bindings.ChannelBinding( - queue={"name": self.queue}, + queue={ + "name": self.queue, + "fifo": self.queue.endswith(".fifo"), + }, ), ), ), diff --git a/faststream/sqs/subscriber/usecase.py b/faststream/sqs/subscriber/usecase.py index 150fc1c171e..37abcfb1c24 100644 --- a/faststream/sqs/subscriber/usecase.py +++ b/faststream/sqs/subscriber/usecase.py @@ -35,8 +35,13 @@ def __init__( calls: "CallsCollection[SQSRawMessage]", ) -> None: self._sqs_parser = SQSParser() - config.parser = self._sqs_parser.parse_message - config.decoder = self._sqs_parser.decode_message + self._batch = config.batch + if config.batch: + config.parser = self._sqs_parser.parse_batch + config.decoder = self._sqs_parser.decode_batch + else: + config.parser = self._sqs_parser.parse_message + config.decoder = self._sqs_parser.decode_message super().__init__(config, specification, calls) self._queue = config.queue @@ -88,8 +93,12 @@ async def start(self) -> None: self._queue_url = await self._resolve_queue_url() self._sqs_parser.bind(self._outer_config.client, self._queue_url) - self._parser = self._sqs_parser.parse_message - self._decoder = self._sqs_parser.decode_message + if self._batch: + self._parser = self._sqs_parser.parse_batch + self._decoder = self._sqs_parser.decode_batch + else: + self._parser = self._sqs_parser.parse_message + self._decoder = self._sqs_parser.decode_message if self.calls: self.add_task(self._consume_loop) @@ -102,6 +111,8 @@ def _receive_kwargs(self) -> dict[str, Any]: "MaxNumberOfMessages": self._max_messages, "WaitTimeSeconds": self._wait_time_seconds, "MessageAttributeNames": ["All"], + # system attributes (ApproximateReceiveCount, MessageGroupId, ...) + "AttributeNames": ["All"], } if self._visibility_timeout is not None: kwargs["VisibilityTimeout"] = self._visibility_timeout @@ -126,6 +137,24 @@ async def _consume_loop(self) -> None: continue backoff = 1.0 + await self._dispatch(messages) + + async def _dispatch(self, messages: list["SQSRawMessage"]) -> None: + """Hand received messages to the handler. + + In batch mode the whole poll result is one ``consume`` call (the handler + receives a list); otherwise each message is consumed individually. + """ + # TODO: visibility-timeout heartbeat for long-running handlers. + # If a handler runs longer than the queue's VisibilityTimeout, the message + # becomes visible again and is redelivered (duplicate processing). For now + # the guidance is to raise `visibility_timeout` on the subscriber. A future + # enhancement could spawn a background task per in-flight message that calls + # `change_message_visibility` periodically (opt-in via e.g. `extend_visibility`). + if self._batch: + if messages: + await self.consume(messages) + else: for message in messages: await self.consume(message) @@ -150,16 +179,20 @@ async def get_one( MaxNumberOfMessages=1, WaitTimeSeconds=min(int(timeout), 20), MessageAttributeNames=["All"], + AttributeNames=["All"], ) messages = resp.get("Messages", []) raw_msg = messages[0] if messages else None context = self._outer_config.fd_config.context + async_parser, async_decoder = self._get_parser_and_decoder() + # In batch mode the parser expects a list of raw messages. + msg_input: Any = [raw_msg] if (self._batch and raw_msg is not None) else raw_msg return await process_msg( - msg=raw_msg, - middlewares=(m(raw_msg, context=context) for m in self._broker_middlewares), - parser=self._sqs_parser.parse_message, - decoder=self._sqs_parser.decode_message, + msg=msg_input, + middlewares=(m(msg_input, context=context) for m in self._broker_middlewares), + parser=async_parser, + decoder=async_decoder, ) @override @@ -169,16 +202,21 @@ async def __aiter__(self) -> AsyncIterator["SQSMessage"]: # type: ignore[overri self._sqs_parser.bind(self._outer_config.client, self._queue_url) context = self._outer_config.fd_config.context + async_parser, async_decoder = self._get_parser_and_decoder() while True: with suppress(ClientError, BotoCoreError): - for raw_msg in await self._receive(): + received = await self._receive() + # In batch mode each poll result is yielded as one batch message. + inputs: list[Any] = [received] if self._batch else list(received) + for raw_msg in inputs: + if self._batch and not raw_msg: + continue msg: SQSMessage = await process_msg( # type: ignore[assignment] msg=raw_msg, middlewares=( - m(raw_msg, context=context) - for m in self._broker_middlewares + m(raw_msg, context=context) for m in self._broker_middlewares ), - parser=self._sqs_parser.parse_message, - decoder=self._sqs_parser.decode_message, + parser=async_parser, + decoder=async_decoder, ) yield msg diff --git a/faststream/sqs/testing.py b/faststream/sqs/testing.py index 331da6f16f1..3dc953091f7 100644 --- a/faststream/sqs/testing.py +++ b/faststream/sqs/testing.py @@ -84,7 +84,9 @@ async def _idle(**_: Any) -> dict[str, Any]: fake_client.create_queue = AsyncMock(side_effect=_echo_url) fake_client.receive_message = AsyncMock(side_effect=_idle) fake_client.delete_message = AsyncMock(return_value={}) + fake_client.delete_message_batch = AsyncMock(return_value={}) fake_client.change_message_visibility = AsyncMock(return_value={}) + fake_client.change_message_visibility_batch = AsyncMock(return_value={}) broker.config.broker_config._client = fake_client return fake_client @@ -129,7 +131,8 @@ async def publish(self, cmd: "SQSPublishCommand") -> None: for handler in self.subscribers: if _queue_matches(handler.queue, cmd.destination): - await handler.process_message(msg) + msg_to_send = [msg] if handler._batch else msg + await handler.process_message(msg_to_send) @override async def publish_batch(self, cmd: "SQSPublishCommand") -> None: @@ -160,8 +163,9 @@ async def request(self, cmd: "SQSPublishCommand") -> Any: if not _queue_matches(handler.queue, cmd.destination): continue + msg_to_send = [msg] if handler._batch else msg with anyio.fail_after(cmd.timeout or 30.0): - result = await handler.process_message(msg) + result = await handler.process_message(msg_to_send) return await build_message( message=result.body, diff --git a/tests/brokers/sqs/test_features.py b/tests/brokers/sqs/test_features.py new file mode 100644 index 00000000000..c01618a709b --- /dev/null +++ b/tests/brokers/sqs/test_features.py @@ -0,0 +1,262 @@ +"""Tests for SQS-specific behaviour added for broker parity. + +Covers producer guards (batch chunking, attribute/size limits, FIFO validation), +parser typing/system-attributes, configurable nack and batch consumption. +""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from faststream import Context +from faststream.response.publish_type import PublishType +from faststream.sqs import FifoQueue +from faststream.sqs.exceptions import ( + MAX_MESSAGE_SIZE, + FifoQueueError, + MessageTooLargeError, + TooManyMessageAttributesError, +) +from faststream.sqs.message import SQSBatchMessage, SQSMessage +from faststream.sqs.parser import SQSParser +from faststream.sqs.publisher.producer import SQSFastProducer +from faststream.sqs.response import SQSPublishCommand + +from .basic import SQSMemoryTestcaseConfig, SQSTestcaseConfig + + +def _attr(value: str) -> dict[str, str]: + return {"DataType": "String", "StringValue": value} + + +# --------------------------------------------------------------------------- # +# Producer guards +# --------------------------------------------------------------------------- # + + +class TestProducerGuards: + def test_chunk_entries_splits_over_ten(self) -> None: + entries = [{"MessageBody": "x", "MessageAttributes": {}} for _ in range(23)] + chunks = list(SQSFastProducer._chunk_entries(entries)) + assert [len(c) for c in chunks] == [10, 10, 3] + + def test_chunk_entries_splits_on_size(self) -> None: + big = "a" * (MAX_MESSAGE_SIZE // 2 + 1) + entries = [{"MessageBody": big, "MessageAttributes": {}} for _ in range(3)] + chunks = list(SQSFastProducer._chunk_entries(entries)) + # Each entry is >half the limit, so no two fit together. + assert all(len(c) == 1 for c in chunks) + assert len(chunks) == 3 + + def test_too_many_attributes(self) -> None: + producer = SQSFastProducer() + attrs = {f"h{i}": _attr("v") for i in range(11)} + with pytest.raises(TooManyMessageAttributesError): + producer._validate_message("body", attrs) + + def test_message_too_large(self) -> None: + producer = SQSFastProducer() + body = "a" * (MAX_MESSAGE_SIZE + 1) + with pytest.raises(MessageTooLargeError): + producer._validate_message(body, {}) + + def test_fifo_requires_group_id(self) -> None: + cmd = SQSPublishCommand( + "data", + queue="orders.fifo", + _publish_type=PublishType.PUBLISH, + ) + with pytest.raises(FifoQueueError): + SQSFastProducer._validate_fifo(cmd) + + def test_fifo_ok_with_group_id(self) -> None: + cmd = SQSPublishCommand( + "data", + queue="orders.fifo", + group_id="g1", + _publish_type=PublishType.PUBLISH, + ) + # Should not raise. + SQSFastProducer._validate_fifo(cmd) + + +# --------------------------------------------------------------------------- # +# Parser typing & system attributes +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio() +class TestParser: + async def test_binary_attribute_decoded_as_bytes(self) -> None: + parser = SQSParser() + raw = { + "Body": "hello", + "MessageAttributes": { + "blob": {"DataType": "Binary", "BinaryValue": b"\x00\x01"}, + "num": {"DataType": "Number", "StringValue": "42"}, + }, + } + msg = await parser.parse_message(raw) + assert msg.headers["blob"] == b"\x00\x01" + assert msg.headers["num"] == "42" + + async def test_system_attributes_exposed(self) -> None: + parser = SQSParser() + raw = { + "Body": "x", + "Attributes": { + "ApproximateReceiveCount": "3", + "MessageGroupId": "g1", + "SequenceNumber": "1234", + "SentTimestamp": "1700000000000", + }, + } + msg = await parser.parse_message(raw) + assert msg.approximate_receive_count == 3 + assert msg.group_id == "g1" + assert msg.sequence_number == "1234" + assert msg.sent_timestamp == 1700000000000 + + async def test_empty_receive_count_defaults_to_zero(self) -> None: + parser = SQSParser() + msg = await parser.parse_message({"Body": "x"}) + assert msg.approximate_receive_count == 0 + + +# --------------------------------------------------------------------------- # +# Message ack/nack semantics +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio() +class TestMessageAck: + async def test_nack_default_immediate(self) -> None: + client = AsyncMock() + msg = SQSMessage(raw_message={"ReceiptHandle": "rh"}, body=b"") + msg.sqs_client = client + msg.queue_url = "url" + await msg.nack() + client.change_message_visibility.assert_awaited_once_with( + QueueUrl="url", ReceiptHandle="rh", VisibilityTimeout=0 + ) + + async def test_nack_with_backoff(self) -> None: + client = AsyncMock() + msg = SQSMessage(raw_message={"ReceiptHandle": "rh"}, body=b"") + msg.sqs_client = client + msg.queue_url = "url" + await msg.nack(visibility_timeout=30) + client.change_message_visibility.assert_awaited_once_with( + QueueUrl="url", ReceiptHandle="rh", VisibilityTimeout=30 + ) + + async def test_batch_ack_deletes_all(self) -> None: + client = AsyncMock() + raw = [{"ReceiptHandle": f"rh{i}"} for i in range(3)] + msg = SQSBatchMessage(raw_message=raw, body=[b"", b"", b""]) + msg.sqs_client = client + msg.queue_url = "url" + await msg.ack() + client.delete_message_batch.assert_awaited_once() + _, kwargs = client.delete_message_batch.call_args + assert {e["ReceiptHandle"] for e in kwargs["Entries"]} == {"rh0", "rh1", "rh2"} + + +# --------------------------------------------------------------------------- # +# Batch consumption (in-memory) +# --------------------------------------------------------------------------- # + + +@pytest.mark.asyncio() +class TestBatchConsume(SQSMemoryTestcaseConfig): + async def test_batch_subscriber_receives_list(self, queue: str) -> None: + broker = self.get_broker() + + @broker.subscriber(queue, batch=True) + async def handler(msg) -> None: + pass + + async with self.patch_broker(broker) as br: + await br.publish("hello", queue) + handler.mock.assert_called_once_with(["hello"]) + + +# --------------------------------------------------------------------------- # +# Connected (LocalStack) coverage of the new behaviour +# --------------------------------------------------------------------------- # + + +@pytest.mark.connected() +@pytest.mark.sqs() +@pytest.mark.asyncio() +class TestConnectedFeatures(SQSTestcaseConfig): + async def test_batch_consume(self, queue: str, event: asyncio.Event) -> None: + broker = self.get_broker(apply_types=True) + received: list = [] + + @broker.subscriber(queue, batch=True) + async def handler(msgs: list) -> None: + received.extend(msgs) + event.set() + + async with broker: + await broker.start() + await asyncio.wait( + ( + asyncio.create_task(broker.publish_batch("a", "b", "c", queue=queue)), + asyncio.create_task(event.wait()), + ), + timeout=self.timeout, + ) + + assert event.is_set() + assert set(received) == {"a", "b", "c"} + + async def test_system_attributes_present( + self, queue: str, event: asyncio.Event + ) -> None: + broker = self.get_broker(apply_types=True) + counts: list[int] = [] + + @broker.subscriber(queue) + async def handler(msg: SQSMessage = Context("message")) -> None: + counts.append(msg.approximate_receive_count) + event.set() + + async with broker: + await broker.start() + await asyncio.wait( + ( + asyncio.create_task(broker.publish("hello", queue)), + asyncio.create_task(event.wait()), + ), + timeout=self.timeout, + ) + + assert event.is_set() + assert counts + assert counts[0] >= 1 + + async def test_fifo_publish_consume(self, queue: str, event: asyncio.Event) -> None: + broker = self.get_broker(apply_types=True) + fifo = FifoQueue(name=f"{queue}.fifo", content_based_deduplication=True) + received: list[str] = [] + + @broker.subscriber(fifo) + async def handler(msg: str) -> None: + received.append(msg) + event.set() + + async with broker: + await broker.start() + await asyncio.wait( + ( + asyncio.create_task(broker.publish("ordered", fifo, group_id="g1")), + asyncio.create_task(event.wait()), + ), + timeout=self.timeout, + ) + + assert event.is_set() + assert received == ["ordered"] diff --git a/tests/brokers/sqs/test_requests.py b/tests/brokers/sqs/test_requests.py index 452df9e0cea..a43ebd1170b 100644 --- a/tests/brokers/sqs/test_requests.py +++ b/tests/brokers/sqs/test_requests.py @@ -1,8 +1,10 @@ +import uuid from typing import Any import pytest from faststream import BaseMiddleware +from faststream.sqs.broker.broker import SQSBroker from tests.brokers.base.requests import RequestsTestcase from .basic import SQSMemoryTestcaseConfig, SQSTestcaseConfig @@ -22,13 +24,21 @@ class SQSRequestsTestcase(RequestsTestcase): def get_middleware(self, **kwargs: Any): return Mid + def _with_response_queue(self, kwargs: dict[str, Any]) -> None: + # SQS has no native RPC; a per-broker reply queue is required. + kwargs.setdefault("response_queue", f"responses-{uuid.uuid4().hex}") + @pytest.mark.connected() @pytest.mark.sqs() class TestRealRequests(SQSTestcaseConfig, SQSRequestsTestcase): - pass + def get_broker(self, apply_types: bool = False, **kwargs: Any) -> SQSBroker: + self._with_response_queue(kwargs) + return super().get_broker(apply_types=apply_types, **kwargs) @pytest.mark.sqs() class TestRequestTestClient(SQSMemoryTestcaseConfig, SQSRequestsTestcase): - pass + def get_broker(self, apply_types: bool = False, **kwargs: Any) -> SQSBroker: + self._with_response_queue(kwargs) + return super().get_broker(apply_types=apply_types, **kwargs) diff --git a/uv.lock b/uv.lock index 127ddd8ace4..93de5071d30 100644 --- a/uv.lock +++ b/uv.lock @@ -1062,7 +1062,7 @@ wheels = [ [[package]] name = "faststream" -version = "0.7.0" +version = "0.7.1" source = { editable = "." } dependencies = [ { name = "anyio" }, From d5e16ff42245721c0ba7a3686cd94a3376117d45 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sun, 7 Jun 2026 22:31:46 +0300 Subject: [PATCH 05/19] ci(github): add SQS integration tests to CI pipeline Add new GitHub Actions jobs to validate SQS functionality: - `test-sqs-smoke`: runs unit tests with mocked SQS - `test-sqs-real`: runs integration tests using LocalStack Includes coverage reporting and artifact uploading for SQS tests. --- .github/workflows/pr_tests.yaml | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/pr_tests.yaml b/.github/workflows/pr_tests.yaml index ac2346e1128..af2fdcbfd82 100644 --- a/.github/workflows/pr_tests.yaml +++ b/.github/workflows/pr_tests.yaml @@ -591,6 +591,72 @@ jobs: if-no-files-found: error include-hidden-files: true + test-sqs-smoke: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "latest" + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + - name: Install Dependencies + run: | + uv pip install --system --group optionals --group testing . + - name: Test + run: > + pytest -n auto + -vv -m "sqs and not connected" + + test-sqs-real: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + needs: + - test-basic + - test-sqs-smoke + services: + localstack: + image: localstack/localstack:4 + ports: + - 4566:4566 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "latest" + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + - name: Install Dependencies + run: | + uv pip install --system --group optionals --group testing . + - name: Test + env: + SQS_ENDPOINT_URL: "http://localhost:4566" + AWS_ACCESS_KEY_ID: "test" + AWS_SECRET_ACCESS_KEY: "test" + AWS_REGION: "us-east-1" + run: > + pytest --cov --cov-report= + -vv -m "(slow and sqs and connected) or (sqs and connected)" + - name: Rename coverage file + run: mkdir coverage && mv .coverage coverage/.coverage.sqs-py + - name: Store coverage files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: .coverage.sqs-py + path: coverage + if-no-files-found: error + include-hidden-files: true + coverage-combine: if: github.event.pull_request.draft == false needs: @@ -604,6 +670,7 @@ jobs: - test-redis-real - test-redis-cluster-real - test-mqtt-real + - test-sqs-real runs-on: ubuntu-latest steps: From 28570648a103d9133c99453fff0c6a2958595728 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Mon, 8 Jun 2026 21:15:59 +0300 Subject: [PATCH 06/19] docs: add SQS documentation and comprehensive tests Add extensive documentation and test coverage for the SQS broker, including: - New documentation files for SQS features: acknowledgement policies, FIFO queues, publishing, RPC, security, and subscriptions. - Documentation source files for generating examples. - Comprehensive AsyncAPI schema tests for SQS (v2.6.0 and v3.0.0). - Integration tests for SQS with FastAPI, OpenTelemetry, and Prometheus. - Tests for SQS-specific middleware and telemetry providers. - Tests for SQS broker functionality including manual acknowledgement, DLQs, and batch publishing. --- docs/docs/en/sqs/ack.md | 22 +--- docs/docs/en/sqs/fifo.md | 23 +--- docs/docs/en/sqs/index.md | 40 +------ docs/docs/en/sqs/publishing.md | 22 +--- docs/docs/en/sqs/rpc.md | 14 +-- docs/docs/en/sqs/security.md | 18 +--- docs/docs/en/sqs/subscription.md | 23 +--- docs/docs_src/sqs/__init__.py | 0 docs/docs_src/sqs/ack/__init__.py | 0 docs/docs_src/sqs/ack/dlq.py | 26 +++++ docs/docs_src/sqs/ack/manual.py | 21 ++++ docs/docs_src/sqs/ack/policy.py | 18 ++++ docs/docs_src/sqs/fifo/__init__.py | 0 docs/docs_src/sqs/fifo/app.py | 39 +++++++ docs/docs_src/sqs/index/__init__.py | 0 docs/docs_src/sqs/index/basic.py | 24 +++++ docs/docs_src/sqs/index/localstack.py | 24 +++++ docs/docs_src/sqs/index/testing.py | 17 +++ docs/docs_src/sqs/publishing/__init__.py | 0 docs/docs_src/sqs/publishing/batch.py | 23 ++++ .../docs_src/sqs/publishing/declared_queue.py | 25 +++++ docs/docs_src/sqs/publishing/publish.py | 32 ++++++ .../sqs/publishing/publisher_object.py | 23 ++++ docs/docs_src/sqs/rpc/__init__.py | 0 docs/docs_src/sqs/rpc/app.py | 24 +++++ docs/docs_src/sqs/security/__init__.py | 0 docs/docs_src/sqs/security/explicit.py | 23 ++++ docs/docs_src/sqs/security/sasl.py | 26 +++++ docs/docs_src/sqs/subscription/__init__.py | 0 docs/docs_src/sqs/subscription/basic.py | 23 ++++ .../sqs/subscription/declared_queue.py | 18 ++++ .../docs_src/sqs/subscription/message_info.py | 20 ++++ tests/asyncapi/sqs/__init__.py | 3 + tests/asyncapi/sqs/v2_6_0/__init__.py | 3 + tests/asyncapi/sqs/v2_6_0/test_arguments.py | 25 +++++ tests/asyncapi/sqs/v2_6_0/test_connection.py | 53 +++++++++ tests/asyncapi/sqs/v2_6_0/test_fastapi.py | 29 +++++ tests/asyncapi/sqs/v2_6_0/test_naming.py | 55 ++++++++++ tests/asyncapi/sqs/v2_6_0/test_publisher.py | 25 +++++ tests/asyncapi/sqs/v2_6_0/test_router.py | 90 ++++++++++++++++ tests/asyncapi/sqs/v3_0_0/__init__.py | 3 + tests/asyncapi/sqs/v3_0_0/test_arguments.py | 25 +++++ tests/asyncapi/sqs/v3_0_0/test_connection.py | 57 ++++++++++ tests/asyncapi/sqs/v3_0_0/test_fastapi.py | 29 +++++ tests/asyncapi/sqs/v3_0_0/test_naming.py | 68 ++++++++++++ tests/asyncapi/sqs/v3_0_0/test_publisher.py | 25 +++++ tests/asyncapi/sqs/v3_0_0/test_router.py | 101 ++++++++++++++++++ tests/brokers/sqs/test_fastapi.py | 47 ++++++++ tests/docs/sqs/__init__.py | 3 + tests/docs/sqs/ack/__init__.py | 0 tests/docs/sqs/ack/test_ack.py | 5 + tests/docs/sqs/fifo/__init__.py | 0 tests/docs/sqs/fifo/test_fifo.py | 3 + tests/docs/sqs/index/__init__.py | 0 tests/docs/sqs/index/test_index.py | 5 + tests/docs/sqs/publishing/__init__.py | 0 tests/docs/sqs/publishing/test_publishing.py | 11 ++ tests/docs/sqs/rpc/__init__.py | 0 tests/docs/sqs/rpc/test_rpc.py | 3 + tests/docs/sqs/security/__init__.py | 0 tests/docs/sqs/security/test_security.py | 4 + tests/docs/sqs/subscription/__init__.py | 0 .../sqs/subscription/test_subscription.py | 5 + tests/opentelemetry/sqs/__init__.py | 3 + tests/opentelemetry/sqs/test_sqs.py | 95 ++++++++++++++++ tests/prometheus/sqs/__init__.py | 3 + tests/prometheus/sqs/basic.py | 15 +++ tests/prometheus/sqs/test_provider.py | 38 +++++++ tests/prometheus/sqs/test_sqs.py | 36 +++++++ 69 files changed, 1291 insertions(+), 144 deletions(-) create mode 100644 docs/docs_src/sqs/__init__.py create mode 100644 docs/docs_src/sqs/ack/__init__.py create mode 100644 docs/docs_src/sqs/ack/dlq.py create mode 100644 docs/docs_src/sqs/ack/manual.py create mode 100644 docs/docs_src/sqs/ack/policy.py create mode 100644 docs/docs_src/sqs/fifo/__init__.py create mode 100644 docs/docs_src/sqs/fifo/app.py create mode 100644 docs/docs_src/sqs/index/__init__.py create mode 100644 docs/docs_src/sqs/index/basic.py create mode 100644 docs/docs_src/sqs/index/localstack.py create mode 100644 docs/docs_src/sqs/index/testing.py create mode 100644 docs/docs_src/sqs/publishing/__init__.py create mode 100644 docs/docs_src/sqs/publishing/batch.py create mode 100644 docs/docs_src/sqs/publishing/declared_queue.py create mode 100644 docs/docs_src/sqs/publishing/publish.py create mode 100644 docs/docs_src/sqs/publishing/publisher_object.py create mode 100644 docs/docs_src/sqs/rpc/__init__.py create mode 100644 docs/docs_src/sqs/rpc/app.py create mode 100644 docs/docs_src/sqs/security/__init__.py create mode 100644 docs/docs_src/sqs/security/explicit.py create mode 100644 docs/docs_src/sqs/security/sasl.py create mode 100644 docs/docs_src/sqs/subscription/__init__.py create mode 100644 docs/docs_src/sqs/subscription/basic.py create mode 100644 docs/docs_src/sqs/subscription/declared_queue.py create mode 100644 docs/docs_src/sqs/subscription/message_info.py create mode 100644 tests/asyncapi/sqs/__init__.py create mode 100644 tests/asyncapi/sqs/v2_6_0/__init__.py create mode 100644 tests/asyncapi/sqs/v2_6_0/test_arguments.py create mode 100644 tests/asyncapi/sqs/v2_6_0/test_connection.py create mode 100644 tests/asyncapi/sqs/v2_6_0/test_fastapi.py create mode 100644 tests/asyncapi/sqs/v2_6_0/test_naming.py create mode 100644 tests/asyncapi/sqs/v2_6_0/test_publisher.py create mode 100644 tests/asyncapi/sqs/v2_6_0/test_router.py create mode 100644 tests/asyncapi/sqs/v3_0_0/__init__.py create mode 100644 tests/asyncapi/sqs/v3_0_0/test_arguments.py create mode 100644 tests/asyncapi/sqs/v3_0_0/test_connection.py create mode 100644 tests/asyncapi/sqs/v3_0_0/test_fastapi.py create mode 100644 tests/asyncapi/sqs/v3_0_0/test_naming.py create mode 100644 tests/asyncapi/sqs/v3_0_0/test_publisher.py create mode 100644 tests/asyncapi/sqs/v3_0_0/test_router.py create mode 100644 tests/brokers/sqs/test_fastapi.py create mode 100644 tests/docs/sqs/__init__.py create mode 100644 tests/docs/sqs/ack/__init__.py create mode 100644 tests/docs/sqs/ack/test_ack.py create mode 100644 tests/docs/sqs/fifo/__init__.py create mode 100644 tests/docs/sqs/fifo/test_fifo.py create mode 100644 tests/docs/sqs/index/__init__.py create mode 100644 tests/docs/sqs/index/test_index.py create mode 100644 tests/docs/sqs/publishing/__init__.py create mode 100644 tests/docs/sqs/publishing/test_publishing.py create mode 100644 tests/docs/sqs/rpc/__init__.py create mode 100644 tests/docs/sqs/rpc/test_rpc.py create mode 100644 tests/docs/sqs/security/__init__.py create mode 100644 tests/docs/sqs/security/test_security.py create mode 100644 tests/docs/sqs/subscription/__init__.py create mode 100644 tests/docs/sqs/subscription/test_subscription.py create mode 100644 tests/opentelemetry/sqs/__init__.py create mode 100644 tests/opentelemetry/sqs/test_sqs.py create mode 100644 tests/prometheus/sqs/__init__.py create mode 100644 tests/prometheus/sqs/basic.py create mode 100644 tests/prometheus/sqs/test_provider.py create mode 100644 tests/prometheus/sqs/test_sqs.py diff --git a/docs/docs/en/sqs/ack.md b/docs/docs/en/sqs/ack.md index c032f41ee61..a853be439c8 100644 --- a/docs/docs/en/sqs/ack.md +++ b/docs/docs/en/sqs/ack.md @@ -14,21 +14,13 @@ at-least-once delivery). On error the message is **not** deleted and SQS redelivers it once its visibility timeout expires. ```python linenums="1" -from faststream import AckPolicy - -broker = SQSBroker(region_name="us-east-1", ack_policy=AckPolicy.NACK_ON_ERROR) +{! docs_src/sqs/ack/policy.py [ln:3,6] !} ``` ## Manual acknowledgement ```python linenums="1" -from faststream.sqs.annotations import SQSMessage - - -@broker.subscriber("my-queue", ack_policy=AckPolicy.MANUAL) -async def handler(body: str, msg: SQSMessage) -> None: - await msg.ack() # DeleteMessage - # or: await msg.nack() / await msg.reject() +{! docs_src/sqs/ack/manual.py [ln:5,11-14] !} ``` ## Dead-letter queues @@ -36,13 +28,5 @@ async def handler(body: str, msg: SQSMessage) -> None: Route exhausted messages to a DLQ with a redrive policy on the queue: ```python linenums="1" -from faststream.sqs import RedrivePolicy, SQSQueue - -queue = SQSQueue( - name="orders", - redrive_policy=RedrivePolicy( - dead_letter_target_arn="arn:aws:sqs:us-east-1:000000000000:orders-dlq", - max_receive_count=5, - ), -) +{! docs_src/sqs/ack/dlq.py [ln:4,9-15] !} ``` diff --git a/docs/docs/en/sqs/fifo.md b/docs/docs/en/sqs/fifo.md index 5610391d2e4..4ffbe9236d4 100644 --- a/docs/docs/en/sqs/fifo.md +++ b/docs/docs/en/sqs/fifo.md @@ -4,21 +4,7 @@ FIFO queues guarantee ordering and exactly-once processing within a message group. Declare one with `FifoQueue` — the `.fifo` suffix is added automatically: ```python linenums="1" -from faststream.sqs import FifoQueue, SQSBroker - -broker = SQSBroker(region_name="us-east-1") - -orders = FifoQueue( - name="orders", - content_based_deduplication=True, - deduplication_scope="messageGroup", - fifo_throughput_limit="perMessageGroupId", -) - - -@broker.subscriber(orders) -async def handler(msg: str) -> None: - ... +{! docs_src/sqs/fifo/app.py [ln:3-18] !} ``` ## Publishing to FIFO queues @@ -27,12 +13,7 @@ FIFO sends require a `MessageGroupId`. When content-based deduplication is off, also provide a `deduplication_id`: ```python linenums="1" -await broker.publish( - "ordered-event", - orders, - group_id="customer-42", - deduplication_id="evt-1001", -) +{! docs_src/sqs/fifo/app.py [ln:22-27] !} ``` Messages sharing a `group_id` are delivered in strict order; different groups diff --git a/docs/docs/en/sqs/index.md b/docs/docs/en/sqs/index.md index 5fde45a385e..c981386f61a 100644 --- a/docs/docs/en/sqs/index.md +++ b/docs/docs/en/sqs/index.md @@ -20,21 +20,7 @@ search: ## FastStream `SQSBroker` ```python linenums="1" -from faststream import FastStream -from faststream.sqs import SQSBroker - -broker = SQSBroker(region_name="us-east-1") -app = FastStream(broker) - - -@broker.subscriber("my-queue") -async def handler(msg: str) -> None: - print(msg) - - -@app.after_startup -async def publish_hello() -> None: - await broker.publish("Hello, SQS!", "my-queue") +{! docs_src/sqs/index/basic.py [ln:3-17] !} ``` ### Connection parameters @@ -52,12 +38,7 @@ async def publish_hello() -> None: ### Local development with LocalStack ```python linenums="1" -broker = SQSBroker( - endpoint_url="http://localhost:4566", - region_name="us-east-1", - aws_access_key_id="test", - aws_secret_access_key="test", -) +{! docs_src/sqs/index/localstack.py [ln:6-11] !} ``` ## Testing @@ -65,20 +46,5 @@ broker = SQSBroker( Use `TestSQSBroker` to route messages in memory — no AWS connection required: ```python linenums="1" -import pytest -from faststream.sqs import SQSBroker, TestSQSBroker - -broker = SQSBroker() - - -@broker.subscriber("test-queue") -async def handler(msg: str) -> str: - return msg + "!" - - -@pytest.mark.asyncio -async def test_handler() -> None: - async with TestSQSBroker(broker) as br: - await br.publish("hello", "test-queue") - handler.mock.assert_called_once_with("hello") +{! docs_src/sqs/index/testing.py !} ``` diff --git a/docs/docs/en/sqs/publishing.md b/docs/docs/en/sqs/publishing.md index be986a4594d..0f9cafeee28 100644 --- a/docs/docs/en/sqs/publishing.md +++ b/docs/docs/en/sqs/publishing.md @@ -3,12 +3,7 @@ Publish to a queue with `broker.publish`: ```python linenums="1" -await broker.publish( - "payload", - "my-queue", - headers={"trace-id": "abc"}, - delay_seconds=5, -) +{! docs_src/sqs/publishing/publish.py [ln:16-21] !} ``` Headers are sent as SQS **MessageAttributes**. FastStream reserves the @@ -21,10 +16,7 @@ Pass an `SQSQueue` instead of a name to have FastStream create the queue (with the given attributes) on startup: ```python linenums="1" -from faststream.sqs import SQSQueue - -queue = SQSQueue(name="orders", visibility_timeout=60, message_retention_period=86400) -await broker.publish("data", queue) +{! docs_src/sqs/publishing/declared_queue.py [ln:4,9,18] !} ``` ## Publisher objects @@ -32,19 +24,13 @@ await broker.publish("data", queue) Register a reusable publisher with `@broker.publisher`: ```python linenums="1" -publisher = broker.publisher("out-queue", headers={"source": "svc-a"}) - - -@publisher -@broker.subscriber("in-queue") -async def handler(msg: str) -> str: - return msg.upper() # return value is published to "out-queue" +{! docs_src/sqs/publishing/publisher_object.py [ln:9,12-15] !} ``` ## Batch publishing ```python linenums="1" -await broker.publish_batch("a", "b", "c", queue="my-queue") +{! docs_src/sqs/publishing/batch.py [ln:16] !} ``` A single `SendMessageBatch` request carries up to 10 messages. diff --git a/docs/docs/en/sqs/rpc.md b/docs/docs/en/sqs/rpc.md index 9bb29ae8f9d..626879f9467 100644 --- a/docs/docs/en/sqs/rpc.md +++ b/docs/docs/en/sqs/rpc.md @@ -5,19 +5,7 @@ SQS has no native request/reply, so FastStream implements RPC with a dedicated broker, then call `broker.request`: ```python linenums="1" -from faststream.sqs import SQSBroker - -broker = SQSBroker(region_name="us-east-1", response_queue="responses") - - -@broker.subscriber("echo") -async def echo(msg: str) -> str: - return f"reply: {msg}" - - -async def call() -> None: - response = await broker.request("ping", "echo", timeout=10.0) - assert await response.decode() == "reply: ping" +{! docs_src/sqs/rpc/app.py [ln:3-17] !} ``` How it works: diff --git a/docs/docs/en/sqs/security.md b/docs/docs/en/sqs/security.md index a68f907e0ee..5caf40f52f2 100644 --- a/docs/docs/en/sqs/security.md +++ b/docs/docs/en/sqs/security.md @@ -7,12 +7,7 @@ FastStream security object. ## Explicit credentials ```python linenums="1" -broker = SQSBroker( - region_name="us-east-1", - aws_access_key_id="AKIA...", - aws_secret_access_key="...", - aws_session_token="...", # optional, for temporary credentials -) +{! docs_src/sqs/security/explicit.py [ln:6-11] !} ``` ## Security objects @@ -21,16 +16,7 @@ broker = SQSBroker( `BaseSecurity` controls TLS: ```python linenums="1" -from faststream.security import SASLPlaintext - -broker = SQSBroker( - region_name="us-east-1", - security=SASLPlaintext( - username="AKIA...", - password="...", - use_ssl=True, - ), -) +{! docs_src/sqs/security/sasl.py [ln:4,7-14] !} ``` The configured security also drives the generated AsyncAPI schema. diff --git a/docs/docs/en/sqs/subscription.md b/docs/docs/en/sqs/subscription.md index 227a3bd27cd..24072a8c5a7 100644 --- a/docs/docs/en/sqs/subscription.md +++ b/docs/docs/en/sqs/subscription.md @@ -4,14 +4,7 @@ Each subscriber long-polls its queue in a background task and dispatches every received message to your handler: ```python linenums="1" -@broker.subscriber( - "my-queue", - wait_time_seconds=10, # long-poll wait (0-20) - max_messages=10, # messages per receive (1-10) - visibility_timeout=30, # per-receive visibility override -) -async def handler(msg: str) -> None: - ... +{! docs_src/sqs/subscription/basic.py [ln:10-16] !} ``` | Parameter | Description | @@ -23,12 +16,7 @@ async def handler(msg: str) -> None: ## Accessing message metadata ```python linenums="1" -from faststream.sqs.annotations import SQSMessage - - -@broker.subscriber("my-queue") -async def handler(body: str, msg: SQSMessage) -> None: - print(msg.message_id, msg.headers, msg.correlation_id) +{! docs_src/sqs/subscription/message_info.py [ln:5,11-13] !} ``` ## Declaring queues on subscribe @@ -36,10 +24,5 @@ async def handler(body: str, msg: SQSMessage) -> None: Pass an `SQSQueue`/`FifoQueue` to create the queue automatically on startup: ```python linenums="1" -from faststream.sqs import SQSQueue - - -@broker.subscriber(SQSQueue(name="orders", visibility_timeout=60)) -async def handler(msg: str) -> None: - ... +{! docs_src/sqs/subscription/declared_queue.py [ln:4,10-11] !} ``` diff --git a/docs/docs_src/sqs/__init__.py b/docs/docs_src/sqs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/docs_src/sqs/ack/__init__.py b/docs/docs_src/sqs/ack/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/docs_src/sqs/ack/dlq.py b/docs/docs_src/sqs/ack/dlq.py new file mode 100644 index 00000000000..9fd319ae0a7 --- /dev/null +++ b/docs/docs_src/sqs/ack/dlq.py @@ -0,0 +1,26 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import RedrivePolicy, SQSBroker, SQSQueue, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + +queue = SQSQueue( + name="orders", + redrive_policy=RedrivePolicy( + dead_letter_target_arn="arn:aws:sqs:us-east-1:000000000000:orders-dlq", + max_receive_count=5, + ), +) + + +@broker.subscriber(queue) +async def handler(msg: str) -> None: ... + + +@pytest.mark.asyncio +async def test_dlq() -> None: + async with TestSQSBroker(broker): + await broker.publish("hi", queue) + handler.mock.assert_called_once_with("hi") diff --git a/docs/docs_src/sqs/ack/manual.py b/docs/docs_src/sqs/ack/manual.py new file mode 100644 index 00000000000..27ba03a8c93 --- /dev/null +++ b/docs/docs_src/sqs/ack/manual.py @@ -0,0 +1,21 @@ +import pytest + +from faststream import AckPolicy, FastStream +from faststream.sqs import SQSBroker, TestSQSBroker +from faststream.sqs.annotations import SQSMessage + +broker = SQSBroker() +app = FastStream(broker) + + +@broker.subscriber("my-queue", ack_policy=AckPolicy.MANUAL) +async def handler(body: str, msg: SQSMessage) -> None: + await msg.ack() # DeleteMessage + # or: await msg.nack() / await msg.reject() + + +@pytest.mark.asyncio +async def test_manual() -> None: + async with TestSQSBroker(broker): + await broker.publish("hi", "my-queue") + handler.mock.assert_called_once_with("hi") diff --git a/docs/docs_src/sqs/ack/policy.py b/docs/docs_src/sqs/ack/policy.py new file mode 100644 index 00000000000..127412db638 --- /dev/null +++ b/docs/docs_src/sqs/ack/policy.py @@ -0,0 +1,18 @@ +import pytest + +from faststream import AckPolicy, FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker(region_name="us-east-1", ack_policy=AckPolicy.NACK_ON_ERROR) +app = FastStream(broker) + + +@broker.subscriber("my-queue") +async def handler(msg: str) -> None: ... + + +@pytest.mark.asyncio +async def test_policy() -> None: + async with TestSQSBroker(broker): + await broker.publish("hi", "my-queue") + handler.mock.assert_called_once_with("hi") diff --git a/docs/docs_src/sqs/fifo/__init__.py b/docs/docs_src/sqs/fifo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/docs_src/sqs/fifo/app.py b/docs/docs_src/sqs/fifo/app.py new file mode 100644 index 00000000000..abde1325f96 --- /dev/null +++ b/docs/docs_src/sqs/fifo/app.py @@ -0,0 +1,39 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import FifoQueue, SQSBroker, TestSQSBroker + +broker = SQSBroker(region_name="us-east-1") +app = FastStream(broker) + +orders = FifoQueue( + name="orders", + content_based_deduplication=True, + deduplication_scope="messageGroup", + fifo_throughput_limit="perMessageGroupId", +) + + +@broker.subscriber(orders) +async def handler(msg: str) -> None: ... + + +async def publish() -> None: + await broker.publish( + "ordered-event", + orders, + group_id="customer-42", + deduplication_id="evt-1001", + ) + + +@pytest.mark.asyncio +async def test_fifo() -> None: + async with TestSQSBroker(broker): + await broker.publish( + "ordered-event", + orders, + group_id="customer-42", + deduplication_id="evt-1001", + ) + handler.mock.assert_called_once_with("ordered-event") diff --git a/docs/docs_src/sqs/index/__init__.py b/docs/docs_src/sqs/index/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/docs_src/sqs/index/basic.py b/docs/docs_src/sqs/index/basic.py new file mode 100644 index 00000000000..820779c4828 --- /dev/null +++ b/docs/docs_src/sqs/index/basic.py @@ -0,0 +1,24 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker(region_name="us-east-1") +app = FastStream(broker) + + +@broker.subscriber("my-queue") +async def handler(msg: str) -> None: + print(msg) + + +@app.after_startup +async def publish_hello() -> None: + await broker.publish("Hello, SQS!", "my-queue") + + +@pytest.mark.asyncio +async def test_basic() -> None: + async with TestSQSBroker(broker): + await broker.publish("Hello, SQS!", "my-queue") + handler.mock.assert_called_once_with("Hello, SQS!") diff --git a/docs/docs_src/sqs/index/localstack.py b/docs/docs_src/sqs/index/localstack.py new file mode 100644 index 00000000000..e7e13addc44 --- /dev/null +++ b/docs/docs_src/sqs/index/localstack.py @@ -0,0 +1,24 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker( + endpoint_url="http://localhost:4566", + region_name="us-east-1", + aws_access_key_id="test", + aws_secret_access_key="test", +) +app = FastStream(broker) + + +@broker.subscriber("my-queue") +async def handler(msg: str) -> None: + print(msg) + + +@pytest.mark.asyncio +async def test_localstack() -> None: + async with TestSQSBroker(broker): + await broker.publish("hi", "my-queue") + handler.mock.assert_called_once_with("hi") diff --git a/docs/docs_src/sqs/index/testing.py b/docs/docs_src/sqs/index/testing.py new file mode 100644 index 00000000000..fba316ee6c1 --- /dev/null +++ b/docs/docs_src/sqs/index/testing.py @@ -0,0 +1,17 @@ +import pytest + +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() + + +@broker.subscriber("test-queue") +async def handler(msg: str) -> str: + return msg + "!" + + +@pytest.mark.asyncio +async def test_handler() -> None: + async with TestSQSBroker(broker) as br: + await br.publish("hello", "test-queue") + handler.mock.assert_called_once_with("hello") diff --git a/docs/docs_src/sqs/publishing/__init__.py b/docs/docs_src/sqs/publishing/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/docs_src/sqs/publishing/batch.py b/docs/docs_src/sqs/publishing/batch.py new file mode 100644 index 00000000000..4297f6faf47 --- /dev/null +++ b/docs/docs_src/sqs/publishing/batch.py @@ -0,0 +1,23 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + + +@broker.subscriber("my-queue") +async def handler(msg: str) -> None: + print(msg) + + +async def publish() -> None: + await broker.publish_batch("a", "b", "c", queue="my-queue") + + +@pytest.mark.asyncio +async def test_batch() -> None: + async with TestSQSBroker(broker): + await broker.publish_batch("a", "b", "c", queue="my-queue") + assert handler.mock.call_count == 3 diff --git a/docs/docs_src/sqs/publishing/declared_queue.py b/docs/docs_src/sqs/publishing/declared_queue.py new file mode 100644 index 00000000000..4a4f2269aa3 --- /dev/null +++ b/docs/docs_src/sqs/publishing/declared_queue.py @@ -0,0 +1,25 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, SQSQueue, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + +queue = SQSQueue(name="orders", visibility_timeout=60, message_retention_period=86400) + + +@broker.subscriber(queue) +async def handler(msg: str) -> None: + print(msg) + + +async def publish() -> None: + await broker.publish("data", queue) + + +@pytest.mark.asyncio +async def test_declared_queue() -> None: + async with TestSQSBroker(broker): + await broker.publish("data", queue) + handler.mock.assert_called_once_with("data") diff --git a/docs/docs_src/sqs/publishing/publish.py b/docs/docs_src/sqs/publishing/publish.py new file mode 100644 index 00000000000..dd476ee10cc --- /dev/null +++ b/docs/docs_src/sqs/publishing/publish.py @@ -0,0 +1,32 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + + +@broker.subscriber("my-queue") +async def handler(msg: str) -> None: + print(msg) + + +async def publish() -> None: + await broker.publish( + "payload", + "my-queue", + headers={"trace-id": "abc"}, + delay_seconds=5, + ) + + +@pytest.mark.asyncio +async def test_publish() -> None: + async with TestSQSBroker(broker): + await broker.publish( + "payload", + "my-queue", + headers={"trace-id": "abc"}, + ) + handler.mock.assert_called_once_with("payload") diff --git a/docs/docs_src/sqs/publishing/publisher_object.py b/docs/docs_src/sqs/publishing/publisher_object.py new file mode 100644 index 00000000000..03f8a6b04a9 --- /dev/null +++ b/docs/docs_src/sqs/publishing/publisher_object.py @@ -0,0 +1,23 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + +publisher = broker.publisher("out-queue", headers={"source": "svc-a"}) + + +@publisher +@broker.subscriber("in-queue") +async def handler(msg: str) -> str: + return msg.upper() # return value is published to "out-queue" + + +@pytest.mark.asyncio +async def test_publisher_object() -> None: + async with TestSQSBroker(broker): + await broker.publish("hello", "in-queue") + handler.mock.assert_called_once_with("hello") + publisher.mock.assert_called_once_with("HELLO") diff --git a/docs/docs_src/sqs/rpc/__init__.py b/docs/docs_src/sqs/rpc/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/docs_src/sqs/rpc/app.py b/docs/docs_src/sqs/rpc/app.py new file mode 100644 index 00000000000..346317cf276 --- /dev/null +++ b/docs/docs_src/sqs/rpc/app.py @@ -0,0 +1,24 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker(region_name="us-east-1", response_queue="responses") +app = FastStream(broker) + + +@broker.subscriber("echo") +async def echo(msg: str) -> str: + return f"reply: {msg}" + + +async def call() -> None: + response = await broker.request("ping", "echo", timeout=10.0) + assert await response.decode() == "reply: ping" + + +@pytest.mark.asyncio +async def test_rpc() -> None: + async with TestSQSBroker(broker): + response = await broker.request("ping", "echo", timeout=10.0) + assert await response.decode() == "reply: ping" diff --git a/docs/docs_src/sqs/security/__init__.py b/docs/docs_src/sqs/security/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/docs_src/sqs/security/explicit.py b/docs/docs_src/sqs/security/explicit.py new file mode 100644 index 00000000000..0b5882f213f --- /dev/null +++ b/docs/docs_src/sqs/security/explicit.py @@ -0,0 +1,23 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker( + region_name="us-east-1", + aws_access_key_id="AKIA...", + aws_secret_access_key="...", + aws_session_token="...", # optional, for temporary credentials +) +app = FastStream(broker) + + +@broker.subscriber("my-queue") +async def handler(msg: str) -> None: ... + + +@pytest.mark.asyncio +async def test_explicit() -> None: + async with TestSQSBroker(broker): + await broker.publish("hi", "my-queue") + handler.mock.assert_called_once_with("hi") diff --git a/docs/docs_src/sqs/security/sasl.py b/docs/docs_src/sqs/security/sasl.py new file mode 100644 index 00000000000..90fcfae3427 --- /dev/null +++ b/docs/docs_src/sqs/security/sasl.py @@ -0,0 +1,26 @@ +import pytest + +from faststream import FastStream +from faststream.security import SASLPlaintext +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker( + region_name="us-east-1", + security=SASLPlaintext( + username="AKIA...", + password="...", + use_ssl=True, + ), +) +app = FastStream(broker) + + +@broker.subscriber("my-queue") +async def handler(msg: str) -> None: ... + + +@pytest.mark.asyncio +async def test_sasl() -> None: + async with TestSQSBroker(broker): + await broker.publish("hi", "my-queue") + handler.mock.assert_called_once_with("hi") diff --git a/docs/docs_src/sqs/subscription/__init__.py b/docs/docs_src/sqs/subscription/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/docs/docs_src/sqs/subscription/basic.py b/docs/docs_src/sqs/subscription/basic.py new file mode 100644 index 00000000000..49cefa632b4 --- /dev/null +++ b/docs/docs_src/sqs/subscription/basic.py @@ -0,0 +1,23 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + + +@broker.subscriber( + "my-queue", + wait_time_seconds=10, # long-poll wait (0-20) + max_messages=10, # messages per receive (1-10) + visibility_timeout=30, # per-receive visibility override +) +async def handler(msg: str) -> None: ... + + +@pytest.mark.asyncio +async def test_subscription() -> None: + async with TestSQSBroker(broker): + await broker.publish("hi", "my-queue") + handler.mock.assert_called_once_with("hi") diff --git a/docs/docs_src/sqs/subscription/declared_queue.py b/docs/docs_src/sqs/subscription/declared_queue.py new file mode 100644 index 00000000000..04e88c1184e --- /dev/null +++ b/docs/docs_src/sqs/subscription/declared_queue.py @@ -0,0 +1,18 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, SQSQueue, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + + +@broker.subscriber(SQSQueue(name="orders", visibility_timeout=60)) +async def handler(msg: str) -> None: ... + + +@pytest.mark.asyncio +async def test_declared_queue() -> None: + async with TestSQSBroker(broker): + await broker.publish("hi", "orders") + handler.mock.assert_called_once_with("hi") diff --git a/docs/docs_src/sqs/subscription/message_info.py b/docs/docs_src/sqs/subscription/message_info.py new file mode 100644 index 00000000000..98d20c2ec44 --- /dev/null +++ b/docs/docs_src/sqs/subscription/message_info.py @@ -0,0 +1,20 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker +from faststream.sqs.annotations import SQSMessage + +broker = SQSBroker() +app = FastStream(broker) + + +@broker.subscriber("my-queue") +async def handler(body: str, msg: SQSMessage) -> None: + print(msg.message_id, msg.headers, msg.correlation_id) + + +@pytest.mark.asyncio +async def test_message_info() -> None: + async with TestSQSBroker(broker): + await broker.publish("hi", "my-queue") + handler.mock.assert_called_once_with("hi") diff --git a/tests/asyncapi/sqs/__init__.py b/tests/asyncapi/sqs/__init__.py new file mode 100644 index 00000000000..51e78a042ec --- /dev/null +++ b/tests/asyncapi/sqs/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("aiobotocore") diff --git a/tests/asyncapi/sqs/v2_6_0/__init__.py b/tests/asyncapi/sqs/v2_6_0/__init__.py new file mode 100644 index 00000000000..51e78a042ec --- /dev/null +++ b/tests/asyncapi/sqs/v2_6_0/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("aiobotocore") diff --git a/tests/asyncapi/sqs/v2_6_0/test_arguments.py b/tests/asyncapi/sqs/v2_6_0/test_arguments.py new file mode 100644 index 00000000000..076004d3c69 --- /dev/null +++ b/tests/asyncapi/sqs/v2_6_0/test_arguments.py @@ -0,0 +1,25 @@ +import pytest + +from faststream.sqs import SQSBroker +from tests.asyncapi.base.v2_6_0.arguments import ArgumentsTestcase + + +@pytest.mark.sqs() +class TestArguments(ArgumentsTestcase): + broker_class = SQSBroker + + def test_subscriber_bindings(self) -> None: + broker = self.broker_class() + + @broker.subscriber("test") + async def handle(msg) -> None: ... + + schema = self.get_spec(broker).to_jsonable() + key = tuple(schema["channels"].keys())[0] # noqa: RUF015 + + assert schema["channels"][key]["bindings"] == { + "sqs": { + "queue": {"name": "test", "fifo": False}, + "bindingVersion": "custom", + }, + }, schema["channels"][key]["bindings"] diff --git a/tests/asyncapi/sqs/v2_6_0/test_connection.py b/tests/asyncapi/sqs/v2_6_0/test_connection.py new file mode 100644 index 00000000000..0c6177258c6 --- /dev/null +++ b/tests/asyncapi/sqs/v2_6_0/test_connection.py @@ -0,0 +1,53 @@ +import pytest + +from faststream.specification import Tag +from faststream.sqs import SQSBroker +from tests.asyncapi.base.v2_6_0 import get_2_6_0_schema + + +@pytest.mark.sqs() +def test_base() -> None: + broker = SQSBroker( + endpoint_url="http://localhost:4566", + description="Test description", + tags=(Tag(name="some-tag", description="experimental"),), + ) + schema = get_2_6_0_schema(broker) + + assert schema == { + "asyncapi": "2.6.0", + "channels": {}, + "components": {"messages": {}, "schemas": {}}, + "defaultContentType": "application/json", + "info": {"title": "FastStream", "version": "0.1.0"}, + "servers": { + "development": { + "description": "Test description", + "protocol": "sqs", + "protocolVersion": "custom", + "tags": [{"description": "experimental", "name": "some-tag"}], + "url": "http://localhost:4566", + } + }, + }, schema + + +@pytest.mark.sqs() +def test_custom() -> None: + broker = SQSBroker(specification_url="custom-sqs-url") + schema = get_2_6_0_schema(broker) + + assert schema == { + "asyncapi": "2.6.0", + "channels": {}, + "components": {"messages": {}, "schemas": {}}, + "defaultContentType": "application/json", + "info": {"title": "FastStream", "version": "0.1.0"}, + "servers": { + "development": { + "protocol": "sqs", + "protocolVersion": "custom", + "url": "custom-sqs-url", + } + }, + }, schema diff --git a/tests/asyncapi/sqs/v2_6_0/test_fastapi.py b/tests/asyncapi/sqs/v2_6_0/test_fastapi.py new file mode 100644 index 00000000000..9ab0fe36cd9 --- /dev/null +++ b/tests/asyncapi/sqs/v2_6_0/test_fastapi.py @@ -0,0 +1,29 @@ +from typing import Any + +import pytest + +from faststream._internal.broker import BrokerUsecase +from faststream.specification import Specification +from faststream.sqs.fastapi import SQSRouter +from faststream.sqs.testing import TestSQSBroker +from tests.asyncapi.base.v2_6_0.arguments import FastAPICompatible +from tests.asyncapi.base.v2_6_0.fastapi import FastAPITestCase +from tests.asyncapi.base.v2_6_0.publisher import PublisherTestcase + + +@pytest.mark.sqs() +class TestRouterArguments(FastAPITestCase, FastAPICompatible): + broker_class = SQSRouter + router_class = SQSRouter + broker_wrapper = staticmethod(TestSQSBroker) + + def get_spec(self, broker: BrokerUsecase[Any, Any]) -> Specification: + return super().get_spec(broker.broker) + + +@pytest.mark.sqs() +class TestRouterPublisher(PublisherTestcase): + broker_class = SQSRouter + + def get_spec(self, broker: BrokerUsecase[Any, Any]) -> Specification: + return super().get_spec(broker.broker) diff --git a/tests/asyncapi/sqs/v2_6_0/test_naming.py b/tests/asyncapi/sqs/v2_6_0/test_naming.py new file mode 100644 index 00000000000..245fd4e0c88 --- /dev/null +++ b/tests/asyncapi/sqs/v2_6_0/test_naming.py @@ -0,0 +1,55 @@ +import pytest + +from faststream.sqs import SQSBroker +from tests.asyncapi.base.v2_6_0.naming import NamingTestCase + + +@pytest.mark.sqs() +class TestNaming(NamingTestCase): + broker_class = SQSBroker + + def test_base(self) -> None: + broker = self.broker_class() + + @broker.subscriber("test") + async def handle() -> None: ... + + schema = self.get_spec(broker).to_jsonable() + + assert schema == { + "info": {"title": "FastStream", "version": "0.1.0"}, + "asyncapi": "2.6.0", + "defaultContentType": "application/json", + "servers": { + "development": { + "url": "sqs", + "protocol": "sqs", + "protocolVersion": "custom", + } + }, + "channels": { + "test:Handle": { + "servers": ["development"], + "bindings": { + "sqs": { + "queue": {"name": "test", "fifo": False}, + "bindingVersion": "custom", + } + }, + "publish": { + "bindings": {"sqs": {"bindingVersion": "custom"}}, + "message": {"$ref": "#/components/messages/test:Handle:Message"}, + }, + } + }, + "components": { + "messages": { + "test:Handle:Message": { + "title": "test:Handle:Message", + "correlationId": {"location": "$message.header#/correlation_id"}, + "payload": {"$ref": "#/components/schemas/EmptyPayload"}, + } + }, + "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}}, + }, + } diff --git a/tests/asyncapi/sqs/v2_6_0/test_publisher.py b/tests/asyncapi/sqs/v2_6_0/test_publisher.py new file mode 100644 index 00000000000..0b77a05ebf5 --- /dev/null +++ b/tests/asyncapi/sqs/v2_6_0/test_publisher.py @@ -0,0 +1,25 @@ +import pytest + +from faststream.sqs import SQSBroker +from tests.asyncapi.base.v2_6_0.publisher import PublisherTestcase + + +@pytest.mark.sqs() +class TestArguments(PublisherTestcase): + broker_class = SQSBroker + + def test_publisher_bindings(self) -> None: + broker = self.broker_class() + + @broker.publisher("test") + async def handle(msg) -> None: ... + + schema = self.get_spec(broker).to_jsonable() + key = tuple(schema["channels"].keys())[0] # noqa: RUF015 + + assert schema["channels"][key]["bindings"] == { + "sqs": { + "queue": {"name": "test", "fifo": False}, + "bindingVersion": "custom", + }, + }, schema["channels"][key]["bindings"] diff --git a/tests/asyncapi/sqs/v2_6_0/test_router.py b/tests/asyncapi/sqs/v2_6_0/test_router.py new file mode 100644 index 00000000000..9e373125e76 --- /dev/null +++ b/tests/asyncapi/sqs/v2_6_0/test_router.py @@ -0,0 +1,90 @@ +from typing import Any + +import pytest + +from faststream._internal.broker import BrokerUsecase +from faststream.specification.base import Specification +from faststream.sqs import SQSBroker, SQSPublisher, SQSRoute, SQSRouter +from tests.asyncapi.base.v2_6_0.arguments import ArgumentsTestcase +from tests.asyncapi.base.v2_6_0.publisher import PublisherTestcase +from tests.asyncapi.base.v2_6_0.router import RouterTestcase + + +@pytest.mark.sqs() +class TestRouter(RouterTestcase): + broker_class = SQSBroker + router_class = SQSRouter + route_class = SQSRoute + publisher_class = SQSPublisher + + def test_prefix(self) -> None: + broker = self.broker_class() + + router = self.router_class(prefix="test_") + + @router.subscriber("test") + async def handle(msg) -> None: ... + + broker.include_router(router) + + schema = self.get_spec(broker).to_jsonable() + + assert schema == { + "info": {"title": "FastStream", "version": "0.1.0"}, + "asyncapi": "2.6.0", + "defaultContentType": "application/json", + "servers": { + "development": { + "url": "sqs", + "protocol": "sqs", + "protocolVersion": "custom", + } + }, + "channels": { + "test_test:Handle": { + "servers": ["development"], + "bindings": { + "sqs": { + "queue": {"name": "test_test", "fifo": False}, + "bindingVersion": "custom", + } + }, + "publish": { + "bindings": {"sqs": {"bindingVersion": "custom"}}, + "message": { + "$ref": "#/components/messages/test_test:Handle:Message" + }, + }, + } + }, + "components": { + "messages": { + "test_test:Handle:Message": { + "title": "test_test:Handle:Message", + "correlationId": {"location": "$message.header#/correlation_id"}, + "payload": { + "$ref": "#/components/schemas/Handle:Message:Payload" + }, + } + }, + "schemas": { + "Handle:Message:Payload": {"title": "Handle:Message:Payload"} + }, + }, + } + + +@pytest.mark.sqs() +class TestRouterArguments(ArgumentsTestcase): + broker_class = SQSRouter + + def get_spec(self, *broker: BrokerUsecase[Any, Any]) -> Specification: + return super().get_spec(SQSBroker(routers=broker)) + + +@pytest.mark.sqs() +class TestRouterPublisher(PublisherTestcase): + broker_class = SQSRouter + + def get_spec(self, *broker: BrokerUsecase[Any, Any]) -> Specification: + return super().get_spec(SQSBroker(routers=broker)) diff --git a/tests/asyncapi/sqs/v3_0_0/__init__.py b/tests/asyncapi/sqs/v3_0_0/__init__.py new file mode 100644 index 00000000000..51e78a042ec --- /dev/null +++ b/tests/asyncapi/sqs/v3_0_0/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("aiobotocore") diff --git a/tests/asyncapi/sqs/v3_0_0/test_arguments.py b/tests/asyncapi/sqs/v3_0_0/test_arguments.py new file mode 100644 index 00000000000..68e6755d315 --- /dev/null +++ b/tests/asyncapi/sqs/v3_0_0/test_arguments.py @@ -0,0 +1,25 @@ +import pytest + +from faststream.sqs import SQSBroker +from tests.asyncapi.base.v3_0_0.arguments import ArgumentsTestcase + + +@pytest.mark.sqs() +class TestArguments(ArgumentsTestcase): + broker_class = SQSBroker + + def test_subscriber_bindings(self) -> None: + broker = self.broker_class() + + @broker.subscriber("test") + async def handle(msg) -> None: ... + + schema = self.get_spec(broker).to_jsonable() + key = tuple(schema["channels"].keys())[0] # noqa: RUF015 + + assert schema["channels"][key]["bindings"] == { + "sqs": { + "queue": {"name": "test", "fifo": False}, + "bindingVersion": "custom", + }, + }, schema["channels"][key]["bindings"] diff --git a/tests/asyncapi/sqs/v3_0_0/test_connection.py b/tests/asyncapi/sqs/v3_0_0/test_connection.py new file mode 100644 index 00000000000..125f0d33b71 --- /dev/null +++ b/tests/asyncapi/sqs/v3_0_0/test_connection.py @@ -0,0 +1,57 @@ +import pytest + +from faststream.specification import Tag +from faststream.sqs import SQSBroker +from tests.asyncapi.base.v3_0_0 import get_3_0_0_schema + + +@pytest.mark.sqs() +def test_base() -> None: + broker = SQSBroker( + endpoint_url="http://localhost:4566", + description="Test description", + tags=(Tag(name="some-tag", description="experimental"),), + ) + schema = get_3_0_0_schema(broker) + + assert schema == { + "info": {"title": "FastStream", "version": "0.1.0"}, + "asyncapi": "3.0.0", + "defaultContentType": "application/json", + "servers": { + "development": { + "host": "localhost:4566", + "pathname": "", + "protocol": "sqs", + "description": "Test description", + "protocolVersion": "custom", + "tags": [{"name": "some-tag", "description": "experimental"}], + } + }, + "channels": {}, + "operations": {}, + "components": {"messages": {}, "schemas": {}}, + }, schema + + +@pytest.mark.sqs() +def test_custom() -> None: + broker = SQSBroker(specification_url="custom-sqs-url") + schema = get_3_0_0_schema(broker) + + assert schema == { + "info": {"title": "FastStream", "version": "0.1.0"}, + "asyncapi": "3.0.0", + "defaultContentType": "application/json", + "servers": { + "development": { + "host": "custom-sqs-url", + "pathname": "", + "protocol": "sqs", + "protocolVersion": "custom", + } + }, + "channels": {}, + "operations": {}, + "components": {"messages": {}, "schemas": {}}, + } diff --git a/tests/asyncapi/sqs/v3_0_0/test_fastapi.py b/tests/asyncapi/sqs/v3_0_0/test_fastapi.py new file mode 100644 index 00000000000..96c1ee9f20a --- /dev/null +++ b/tests/asyncapi/sqs/v3_0_0/test_fastapi.py @@ -0,0 +1,29 @@ +from typing import Any + +import pytest + +from faststream._internal.broker import BrokerUsecase +from faststream.specification import Specification +from faststream.sqs.fastapi import SQSRouter +from faststream.sqs.testing import TestSQSBroker +from tests.asyncapi.base.v3_0_0.arguments import FastAPICompatible +from tests.asyncapi.base.v3_0_0.fastapi import FastAPITestCase +from tests.asyncapi.base.v3_0_0.publisher import PublisherTestcase + + +@pytest.mark.sqs() +class TestRouterArguments(FastAPITestCase, FastAPICompatible): + broker_class = SQSRouter + router_class = SQSRouter + broker_wrapper = staticmethod(TestSQSBroker) + + def get_spec(self, broker: BrokerUsecase[Any, Any]) -> Specification: + return super().get_spec(broker.broker) + + +@pytest.mark.sqs() +class TestRouterPublisher(PublisherTestcase): + broker_class = SQSRouter + + def get_spec(self, broker: BrokerUsecase[Any, Any]) -> Specification: + return super().get_spec(broker.broker) diff --git a/tests/asyncapi/sqs/v3_0_0/test_naming.py b/tests/asyncapi/sqs/v3_0_0/test_naming.py new file mode 100644 index 00000000000..3ce85532790 --- /dev/null +++ b/tests/asyncapi/sqs/v3_0_0/test_naming.py @@ -0,0 +1,68 @@ +import pytest + +from faststream.sqs import SQSBroker +from tests.asyncapi.base.v3_0_0.naming import NamingTestCase + + +@pytest.mark.sqs() +class TestNaming(NamingTestCase): + broker_class = SQSBroker + + def test_base(self) -> None: + broker = self.broker_class() + + @broker.subscriber("test") + async def handle() -> None: ... + + schema = self.get_spec(broker).to_jsonable() + + assert schema == { + "info": {"title": "FastStream", "version": "0.1.0"}, + "asyncapi": "3.0.0", + "defaultContentType": "application/json", + "servers": { + "development": { + "host": "sqs", + "pathname": "", + "protocol": "sqs", + "protocolVersion": "custom", + } + }, + "channels": { + "test:Handle": { + "address": "test:Handle", + "servers": [{"$ref": "#/servers/development"}], + "messages": { + "SubscribeMessage": { + "$ref": "#/components/messages/test:Handle:SubscribeMessage" + } + }, + "bindings": { + "sqs": { + "queue": {"name": "test", "fifo": False}, + "bindingVersion": "custom", + } + }, + } + }, + "operations": { + "test:HandleSubscribe": { + "action": "receive", + "channel": {"$ref": "#/channels/test:Handle"}, + "bindings": {"sqs": {"bindingVersion": "custom"}}, + "messages": [ + {"$ref": "#/channels/test:Handle/messages/SubscribeMessage"} + ], + } + }, + "components": { + "messages": { + "test:Handle:SubscribeMessage": { + "title": "test:Handle:SubscribeMessage", + "correlationId": {"location": "$message.header#/correlation_id"}, + "payload": {"$ref": "#/components/schemas/EmptyPayload"}, + } + }, + "schemas": {"EmptyPayload": {"title": "EmptyPayload", "type": "null"}}, + }, + } diff --git a/tests/asyncapi/sqs/v3_0_0/test_publisher.py b/tests/asyncapi/sqs/v3_0_0/test_publisher.py new file mode 100644 index 00000000000..00e8205d79a --- /dev/null +++ b/tests/asyncapi/sqs/v3_0_0/test_publisher.py @@ -0,0 +1,25 @@ +import pytest + +from faststream.sqs import SQSBroker +from tests.asyncapi.base.v3_0_0.publisher import PublisherTestcase + + +@pytest.mark.sqs() +class TestArguments(PublisherTestcase): + broker_class = SQSBroker + + def test_publisher_bindings(self) -> None: + broker = self.broker_class() + + @broker.publisher("test") + async def handle(msg) -> None: ... + + schema = self.get_spec(broker).to_jsonable() + key = tuple(schema["channels"].keys())[0] # noqa: RUF015 + + assert schema["channels"][key]["bindings"] == { + "sqs": { + "queue": {"name": "test", "fifo": False}, + "bindingVersion": "custom", + }, + }, schema["channels"][key]["bindings"] diff --git a/tests/asyncapi/sqs/v3_0_0/test_router.py b/tests/asyncapi/sqs/v3_0_0/test_router.py new file mode 100644 index 00000000000..de07c641009 --- /dev/null +++ b/tests/asyncapi/sqs/v3_0_0/test_router.py @@ -0,0 +1,101 @@ +from typing import Any + +import pytest + +from faststream._internal.broker import BrokerUsecase +from faststream.specification.base import Specification +from faststream.sqs import SQSBroker, SQSPublisher, SQSRoute, SQSRouter +from tests.asyncapi.base.v2_6_0.arguments import ArgumentsTestcase +from tests.asyncapi.base.v2_6_0.publisher import PublisherTestcase +from tests.asyncapi.base.v3_0_0.router import RouterTestcase + + +@pytest.mark.sqs() +class TestRouter(RouterTestcase): + broker_class = SQSBroker + router_class = SQSRouter + route_class = SQSRoute + publisher_class = SQSPublisher + + def test_prefix(self) -> None: + broker = self.broker_class() + + router = self.router_class(prefix="test_") + + @router.subscriber("test") + async def handle(msg) -> None: ... + + broker.include_router(router) + + schema = self.get_spec(broker).to_jsonable() + + assert schema == { + "info": {"title": "FastStream", "version": "0.1.0"}, + "asyncapi": "3.0.0", + "defaultContentType": "application/json", + "servers": { + "development": { + "host": "sqs", + "pathname": "", + "protocol": "sqs", + "protocolVersion": "custom", + } + }, + "channels": { + "test_test:Handle": { + "address": "test_test:Handle", + "servers": [{"$ref": "#/servers/development"}], + "messages": { + "SubscribeMessage": { + "$ref": "#/components/messages/test_test:Handle:SubscribeMessage" + } + }, + "bindings": { + "sqs": { + "queue": {"name": "test_test", "fifo": False}, + "bindingVersion": "custom", + } + }, + } + }, + "operations": { + "test_test:HandleSubscribe": { + "action": "receive", + "channel": {"$ref": "#/channels/test_test:Handle"}, + "bindings": {"sqs": {"bindingVersion": "custom"}}, + "messages": [ + {"$ref": "#/channels/test_test:Handle/messages/SubscribeMessage"} + ], + } + }, + "components": { + "messages": { + "test_test:Handle:SubscribeMessage": { + "title": "test_test:Handle:SubscribeMessage", + "correlationId": {"location": "$message.header#/correlation_id"}, + "payload": { + "$ref": "#/components/schemas/Handle:Message:Payload" + }, + } + }, + "schemas": { + "Handle:Message:Payload": {"title": "Handle:Message:Payload"} + }, + }, + } + + +@pytest.mark.sqs() +class TestRouterArguments(ArgumentsTestcase): + broker_class = SQSRouter + + def get_spec(self, *broker: BrokerUsecase[Any, Any]) -> Specification: + return super().get_spec(SQSBroker(routers=broker)) + + +@pytest.mark.sqs() +class TestRouterPublisher(PublisherTestcase): + broker_class = SQSRouter + + def get_spec(self, *broker: BrokerUsecase[Any, Any]) -> Specification: + return super().get_spec(SQSBroker(routers=broker)) diff --git a/tests/brokers/sqs/test_fastapi.py b/tests/brokers/sqs/test_fastapi.py new file mode 100644 index 00000000000..8a50a461d89 --- /dev/null +++ b/tests/brokers/sqs/test_fastapi.py @@ -0,0 +1,47 @@ +import os +from typing import Any + +import pytest + +from faststream.sqs import SQSRouter +from faststream.sqs.fastapi import SQSRouter as _SQSStreamRouter +from tests.brokers.base.fastapi import FastAPILocalTestcase, FastAPITestcase + +from .basic import SQSMemoryTestcaseConfig, SQSTestcaseConfig + + +class StreamRouter(_SQSStreamRouter): + """FastAPI SQS router pre-wired to LocalStack. + + The shared FastAPI test cases instantiate ``router_class()`` with no + arguments, so the connection defaults have to be baked into the class. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault( + "endpoint_url", + os.environ.get("SQS_ENDPOINT_URL", "http://localhost:4566"), + ) + kwargs.setdefault("region_name", os.environ.get("AWS_REGION", "us-east-1")) + kwargs.setdefault( + "aws_access_key_id", + os.environ.get("AWS_ACCESS_KEY_ID", "test"), + ) + kwargs.setdefault( + "aws_secret_access_key", + os.environ.get("AWS_SECRET_ACCESS_KEY", "test"), + ) + super().__init__(*args, **kwargs) + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestRouter(SQSTestcaseConfig, FastAPITestcase): + router_class = StreamRouter + broker_router_class = SQSRouter + + +@pytest.mark.sqs() +class TestRouterLocal(SQSMemoryTestcaseConfig, FastAPILocalTestcase): + router_class = StreamRouter + broker_router_class = SQSRouter diff --git a/tests/docs/sqs/__init__.py b/tests/docs/sqs/__init__.py new file mode 100644 index 00000000000..51e78a042ec --- /dev/null +++ b/tests/docs/sqs/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("aiobotocore") diff --git a/tests/docs/sqs/ack/__init__.py b/tests/docs/sqs/ack/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/docs/sqs/ack/test_ack.py b/tests/docs/sqs/ack/test_ack.py new file mode 100644 index 00000000000..af6905f3eb1 --- /dev/null +++ b/tests/docs/sqs/ack/test_ack.py @@ -0,0 +1,5 @@ +from docs.docs_src.sqs.ack.dlq import test_dlq +from docs.docs_src.sqs.ack.manual import test_manual +from docs.docs_src.sqs.ack.policy import test_policy + +__all__ = ["test_dlq", "test_manual", "test_policy"] diff --git a/tests/docs/sqs/fifo/__init__.py b/tests/docs/sqs/fifo/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/docs/sqs/fifo/test_fifo.py b/tests/docs/sqs/fifo/test_fifo.py new file mode 100644 index 00000000000..65420cba196 --- /dev/null +++ b/tests/docs/sqs/fifo/test_fifo.py @@ -0,0 +1,3 @@ +from docs.docs_src.sqs.fifo.app import test_fifo + +__all__ = ["test_fifo"] diff --git a/tests/docs/sqs/index/__init__.py b/tests/docs/sqs/index/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/docs/sqs/index/test_index.py b/tests/docs/sqs/index/test_index.py new file mode 100644 index 00000000000..994d68c9d24 --- /dev/null +++ b/tests/docs/sqs/index/test_index.py @@ -0,0 +1,5 @@ +from docs.docs_src.sqs.index.basic import test_basic +from docs.docs_src.sqs.index.localstack import test_localstack +from docs.docs_src.sqs.index.testing import test_handler + +__all__ = ["test_basic", "test_handler", "test_localstack"] diff --git a/tests/docs/sqs/publishing/__init__.py b/tests/docs/sqs/publishing/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/docs/sqs/publishing/test_publishing.py b/tests/docs/sqs/publishing/test_publishing.py new file mode 100644 index 00000000000..83368308fc7 --- /dev/null +++ b/tests/docs/sqs/publishing/test_publishing.py @@ -0,0 +1,11 @@ +from docs.docs_src.sqs.publishing.batch import test_batch +from docs.docs_src.sqs.publishing.declared_queue import test_declared_queue +from docs.docs_src.sqs.publishing.publish import test_publish +from docs.docs_src.sqs.publishing.publisher_object import test_publisher_object + +__all__ = [ + "test_batch", + "test_declared_queue", + "test_publish", + "test_publisher_object", +] diff --git a/tests/docs/sqs/rpc/__init__.py b/tests/docs/sqs/rpc/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/docs/sqs/rpc/test_rpc.py b/tests/docs/sqs/rpc/test_rpc.py new file mode 100644 index 00000000000..41dbf4de4cd --- /dev/null +++ b/tests/docs/sqs/rpc/test_rpc.py @@ -0,0 +1,3 @@ +from docs.docs_src.sqs.rpc.app import test_rpc + +__all__ = ["test_rpc"] diff --git a/tests/docs/sqs/security/__init__.py b/tests/docs/sqs/security/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/docs/sqs/security/test_security.py b/tests/docs/sqs/security/test_security.py new file mode 100644 index 00000000000..1101939331e --- /dev/null +++ b/tests/docs/sqs/security/test_security.py @@ -0,0 +1,4 @@ +from docs.docs_src.sqs.security.explicit import test_explicit +from docs.docs_src.sqs.security.sasl import test_sasl + +__all__ = ["test_explicit", "test_sasl"] diff --git a/tests/docs/sqs/subscription/__init__.py b/tests/docs/sqs/subscription/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/docs/sqs/subscription/test_subscription.py b/tests/docs/sqs/subscription/test_subscription.py new file mode 100644 index 00000000000..214a0fe4d4e --- /dev/null +++ b/tests/docs/sqs/subscription/test_subscription.py @@ -0,0 +1,5 @@ +from docs.docs_src.sqs.subscription.basic import test_subscription +from docs.docs_src.sqs.subscription.declared_queue import test_declared_queue +from docs.docs_src.sqs.subscription.message_info import test_message_info + +__all__ = ["test_declared_queue", "test_message_info", "test_subscription"] diff --git a/tests/opentelemetry/sqs/__init__.py b/tests/opentelemetry/sqs/__init__.py new file mode 100644 index 00000000000..51e78a042ec --- /dev/null +++ b/tests/opentelemetry/sqs/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("aiobotocore") diff --git a/tests/opentelemetry/sqs/test_sqs.py b/tests/opentelemetry/sqs/test_sqs.py new file mode 100644 index 00000000000..876aff9b412 --- /dev/null +++ b/tests/opentelemetry/sqs/test_sqs.py @@ -0,0 +1,95 @@ +from typing import Any + +import pytest +from dirty_equals import IsUUID +from opentelemetry.sdk.trace import Span +from opentelemetry.semconv.trace import SpanAttributes as SpanAttr +from opentelemetry.trace import SpanKind + +from faststream.opentelemetry.consts import MESSAGING_DESTINATION_PUBLISH_NAME +from faststream.opentelemetry.middleware import MessageAction as Action +from faststream.sqs import SQSBroker +from faststream.sqs.opentelemetry import SQSTelemetryMiddleware +from tests.brokers.sqs.basic import SQSTestcaseConfig +from tests.brokers.sqs.test_consume import TestConsume as ConsumeCase +from tests.brokers.sqs.test_publish import TestPublish as PublishCase +from tests.opentelemetry.basic import LocalTelemetryTestcase + + +@pytest.mark.sqs() +@pytest.mark.connected() +class TestTelemetry(SQSTestcaseConfig, LocalTelemetryTestcase): # type: ignore[misc] + messaging_system = "aws_sqs" + include_messages_counters = True + telemetry_middleware_class = SQSTelemetryMiddleware + + def assert_span( + self, + span: Span, + action: str, + queue: str, + msg: str, + parent_span_id: str | None = None, + ) -> None: + # SQS reports the publish side by queue *name* but the consume side by + # queue *URL* (``...//``); the shared base assumes the + # queue name on both sides, so the destination assertions are relaxed + # to ``endswith`` for consumer spans. + attrs = span.attributes or {} + assert attrs[SpanAttr.MESSAGING_SYSTEM] == self.messaging_system, attrs[ + SpanAttr.MESSAGING_SYSTEM + ] + assert attrs[SpanAttr.MESSAGING_MESSAGE_CONVERSATION_ID] == IsUUID, attrs[ + SpanAttr.MESSAGING_MESSAGE_CONVERSATION_ID + ] + assert span.kind in {SpanKind.CONSUMER, SpanKind.PRODUCER}, span.kind + + if span.kind == SpanKind.PRODUCER and action in {Action.CREATE, Action.PUBLISH}: + assert span.name == f"{queue} {action}", span.name + assert attrs[SpanAttr.MESSAGING_DESTINATION_NAME] == queue, attrs[ + SpanAttr.MESSAGING_DESTINATION_NAME + ] + + if span.kind == SpanKind.CONSUMER and action in {Action.CREATE, Action.PROCESS}: + assert span.name.endswith(f"{queue} {action}"), span.name + assert str(attrs[MESSAGING_DESTINATION_PUBLISH_NAME]).endswith(queue), attrs[ + MESSAGING_DESTINATION_PUBLISH_NAME + ] + assert attrs[SpanAttr.MESSAGING_MESSAGE_ID] == IsUUID, attrs[ + SpanAttr.MESSAGING_MESSAGE_ID + ] + + if action == Action.PROCESS: + assert attrs[SpanAttr.MESSAGING_MESSAGE_PAYLOAD_SIZE_BYTES] == len(msg), ( + attrs[SpanAttr.MESSAGING_MESSAGE_PAYLOAD_SIZE_BYTES] + ) + assert attrs[SpanAttr.MESSAGING_OPERATION] == action, attrs[ + SpanAttr.MESSAGING_OPERATION + ] + + if action == Action.PUBLISH: + assert attrs[SpanAttr.MESSAGING_OPERATION] == action, attrs[ + SpanAttr.MESSAGING_OPERATION + ] + + if parent_span_id: + assert span.parent + assert span.parent.span_id == parent_span_id, span.parent.span_id + + +@pytest.mark.sqs() +@pytest.mark.connected() +class TestPublishWithTelemetry(PublishCase): + def get_broker(self, apply_types: bool = False, **kwargs: Any) -> SQSBroker: + broker = super().get_broker(apply_types=apply_types, **kwargs) + broker.add_middleware(SQSTelemetryMiddleware()) + return broker + + +@pytest.mark.sqs() +@pytest.mark.connected() +class TestConsumeWithTelemetry(ConsumeCase): + def get_broker(self, apply_types: bool = False, **kwargs: Any) -> SQSBroker: + broker = super().get_broker(apply_types=apply_types, **kwargs) + broker.add_middleware(SQSTelemetryMiddleware()) + return broker diff --git a/tests/prometheus/sqs/__init__.py b/tests/prometheus/sqs/__init__.py new file mode 100644 index 00000000000..51e78a042ec --- /dev/null +++ b/tests/prometheus/sqs/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("aiobotocore") diff --git a/tests/prometheus/sqs/basic.py b/tests/prometheus/sqs/basic.py new file mode 100644 index 00000000000..4cdd06f008c --- /dev/null +++ b/tests/prometheus/sqs/basic.py @@ -0,0 +1,15 @@ +from typing import Any + +from faststream.sqs.prometheus import SQSPrometheusMiddleware +from faststream.sqs.prometheus.provider import SQSMetricsSettingsProvider +from tests.brokers.sqs.basic import SQSTestcaseConfig + + +class SQSPrometheusSettings(SQSTestcaseConfig): + messaging_system = "aws_sqs" + + def get_middleware(self, **kwargs: Any) -> SQSPrometheusMiddleware: + return SQSPrometheusMiddleware(**kwargs) + + def get_settings_provider(self) -> SQSMetricsSettingsProvider: + return SQSMetricsSettingsProvider() diff --git a/tests/prometheus/sqs/test_provider.py b/tests/prometheus/sqs/test_provider.py new file mode 100644 index 00000000000..3ff08e4737e --- /dev/null +++ b/tests/prometheus/sqs/test_provider.py @@ -0,0 +1,38 @@ +from types import SimpleNamespace + +import pytest + +from tests.prometheus.basic import LocalMetricsSettingsProviderTestcase + +from .basic import SQSPrometheusSettings + + +@pytest.mark.sqs() +class TestSQSMetricsSettingsProvider( + SQSPrometheusSettings, + LocalMetricsSettingsProviderTestcase, +): + def test_get_publish_destination_name_from_cmd(self, queue: str) -> None: + provider = self.get_settings_provider() + command = SimpleNamespace(destination=queue) + + destination_name = provider.get_publish_destination_name_from_cmd(command) + + assert destination_name == queue + + def test_get_consume_attrs_from_message(self, queue: str) -> None: + body = b"Hello" + # SQS reports the consume destination by queue URL. + queue_url = f"http://localhost:4566/000000000000/{queue}" + expected_attrs = { + "destination_name": queue_url, + "message_size": len(body), + "messages_count": 1, + } + + message = SimpleNamespace(body=body, queue_url=queue_url) + + provider = self.get_settings_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs diff --git a/tests/prometheus/sqs/test_sqs.py b/tests/prometheus/sqs/test_sqs.py new file mode 100644 index 00000000000..361e0ab0791 --- /dev/null +++ b/tests/prometheus/sqs/test_sqs.py @@ -0,0 +1,36 @@ +from typing import Any + +import pytest +from prometheus_client import CollectorRegistry + +from faststream.sqs import SQSBroker +from faststream.sqs.prometheus.middleware import SQSPrometheusMiddleware +from tests.brokers.sqs.test_consume import TestConsume as ConsumeCase +from tests.brokers.sqs.test_publish import TestPublish as PublishCase +from tests.prometheus.basic import LocalPrometheusTestcase + +from .basic import SQSPrometheusSettings + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestPrometheus(SQSPrometheusSettings, LocalPrometheusTestcase): + pass + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestPublishWithPrometheus(PublishCase): + def get_broker(self, apply_types: bool = False, **kwargs: Any) -> SQSBroker: + broker = super().get_broker(apply_types=apply_types, **kwargs) + broker.add_middleware(SQSPrometheusMiddleware(registry=CollectorRegistry())) + return broker + + +@pytest.mark.connected() +@pytest.mark.sqs() +class TestConsumeWithPrometheus(ConsumeCase): + def get_broker(self, apply_types: bool = False, **kwargs: Any) -> SQSBroker: + broker = super().get_broker(apply_types=apply_types, **kwargs) + broker.add_middleware(SQSPrometheusMiddleware(registry=CollectorRegistry())) + return broker From 2048005df4babe3d687513b61a2361e2768f6336 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Tue, 9 Jun 2026 00:03:47 +0300 Subject: [PATCH 07/19] feat(sqs): support `request_attempt_id` for FIFO queues Add support for the `ReceiveRequestAttemptId` parameter in SQS subscribers. This allows users to provide a deduplication token during `ReceiveMessage` calls, which is useful for retrying failed receives in FIFO queues to ensure the same batch of messages is returned. - Implement `request_attempt_id` in `SQSRegistrator`, `SQSRouter`, and `SQSSubscriber`. - Add validation to ensure `request_attempt_id` is only used with FIFO queues, raising a `SetupError` otherwise. - Update documentation and add integration tests for the new feature. --- docs/docs/en/sqs/fifo.md | 14 +++++ docs/docs_src/sqs/fifo/request_attempt_id.py | 20 +++++++ faststream/sqs/broker/registrator.py | 6 +++ faststream/sqs/fastapi/fastapi.py | 2 + faststream/sqs/subscriber/config.py | 2 + faststream/sqs/subscriber/factory.py | 13 +++++ faststream/sqs/subscriber/usecase.py | 21 +++++--- tests/brokers/sqs/test_features.py | 56 ++++++++++++++++++++ tests/docs/sqs/fifo/test_fifo.py | 3 +- 9 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 docs/docs_src/sqs/fifo/request_attempt_id.py diff --git a/docs/docs/en/sqs/fifo.md b/docs/docs/en/sqs/fifo.md index 4ffbe9236d4..c034c064144 100644 --- a/docs/docs/en/sqs/fifo.md +++ b/docs/docs/en/sqs/fifo.md @@ -18,3 +18,17 @@ also provide a `deduplication_id`: Messages sharing a `group_id` are delivered in strict order; different groups are processed in parallel. + +## Receive-retry deduplication (`request_attempt_id`) + +For FIFO queues you can pass a `request_attempt_id` to the subscriber. It maps to +the SQS `ReceiveRequestAttemptId` — the deduplication token SQS uses when a +`ReceiveMessage` call fails (e.g. a network error): retrying with the same token +returns the *same* batch of messages instead of leaving them invisible. + +```python linenums="1" +{! docs_src/sqs/fifo/request_attempt_id.py [ln:3-13] !} +``` + +This parameter is FIFO-only — setting it on a standard (non-`.fifo`) queue raises +a `SetupError` at startup. diff --git a/docs/docs_src/sqs/fifo/request_attempt_id.py b/docs/docs_src/sqs/fifo/request_attempt_id.py new file mode 100644 index 00000000000..9b4f9300e3b --- /dev/null +++ b/docs/docs_src/sqs/fifo/request_attempt_id.py @@ -0,0 +1,20 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import FifoQueue, SQSBroker, TestSQSBroker + +broker = SQSBroker(region_name="us-east-1") +app = FastStream(broker) + +orders = FifoQueue(name="orders") + + +@broker.subscriber(orders, request_attempt_id="attempt-1") +async def handler(msg: str) -> None: ... + + +@pytest.mark.asyncio +async def test_request_attempt_id() -> None: + async with TestSQSBroker(broker): + await broker.publish("ordered-event", orders, group_id="customer-42") + handler.mock.assert_called_once_with("ordered-event") diff --git a/faststream/sqs/broker/registrator.py b/faststream/sqs/broker/registrator.py index 11b9760da9d..4e0b32ee90e 100644 --- a/faststream/sqs/broker/registrator.py +++ b/faststream/sqs/broker/registrator.py @@ -34,6 +34,7 @@ def subscriber( # type: ignore[override] max_messages: int = 10, visibility_timeout: int | None = None, batch: bool = False, + request_attempt_id: str | None = None, # broker arguments ack_policy: AckPolicy = EMPTY, no_reply: bool = False, @@ -55,6 +56,10 @@ def subscriber( # type: ignore[override] max_messages: Max messages per receive (1-10). visibility_timeout: Per-receive visibility timeout override. batch: Consume up to ``max_messages`` at once; the handler receives a list. + request_attempt_id: ``ReceiveRequestAttemptId`` for FIFO queues — the + receive-side deduplication token used to retry a failed + ``ReceiveMessage`` call and get the same messages back. FIFO-only; + a ``SetupError`` is raised if set for a non-FIFO queue. ack_policy: Acknowledgement policy for message processing. no_reply: Whether to disable FastStream RPC / reply-to responses. dependencies: Dependencies list to apply to the subscriber. @@ -72,6 +77,7 @@ def subscriber( # type: ignore[override] max_messages=max_messages, visibility_timeout=visibility_timeout, batch=batch, + request_attempt_id=request_attempt_id, ack_policy=ack_policy, no_reply=no_reply, config=cast("SQSBrokerConfig", self.config), diff --git a/faststream/sqs/fastapi/fastapi.py b/faststream/sqs/fastapi/fastapi.py index 1eb07b93101..7b832291e39 100644 --- a/faststream/sqs/fastapi/fastapi.py +++ b/faststream/sqs/fastapi/fastapi.py @@ -155,6 +155,7 @@ def subscriber( # type: ignore[override] wait_time_seconds: int = 5, max_messages: int = 10, visibility_timeout: int | None = None, + request_attempt_id: str | None = None, # broker arguments ack_policy: AckPolicy = EMPTY, no_reply: bool = False, @@ -183,6 +184,7 @@ def subscriber( # type: ignore[override] wait_time_seconds=wait_time_seconds, max_messages=max_messages, visibility_timeout=visibility_timeout, + request_attempt_id=request_attempt_id, ack_policy=ack_policy, no_reply=no_reply, dependencies=dependencies, diff --git a/faststream/sqs/subscriber/config.py b/faststream/sqs/subscriber/config.py index baf829c006b..a4eea7393d0 100644 --- a/faststream/sqs/subscriber/config.py +++ b/faststream/sqs/subscriber/config.py @@ -28,6 +28,8 @@ class SQSSubscriberConfig(SubscriberUsecaseConfig): max_messages: int = 10 visibility_timeout: int | None = None batch: bool = False + # ReceiveRequestAttemptId — FIFO-only receive-retry dedup token. + request_attempt_id: str | None = None @property def ack_policy(self) -> AckPolicy: diff --git a/faststream/sqs/subscriber/factory.py b/faststream/sqs/subscriber/factory.py index a677f0da4b1..6f2a1def793 100644 --- a/faststream/sqs/subscriber/factory.py +++ b/faststream/sqs/subscriber/factory.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING, Any, Union from faststream._internal.endpoint.subscriber.call_item import CallsCollection +from faststream.exceptions import SetupError from faststream.sqs.schemas import SQSQueue from .config import SQSSubscriberConfig, SQSSubscriberSpecificationConfig @@ -19,6 +20,7 @@ def create_subscriber( max_messages: int, visibility_timeout: int | None, batch: bool, + request_attempt_id: str | None, # Subscriber args ack_policy: "AckPolicy", no_reply: bool, @@ -31,6 +33,16 @@ def create_subscriber( queue_obj = queue if isinstance(queue, SQSQueue) else SQSQueue(name=queue) queue_name = queue_obj.queue_name + # ReceiveRequestAttemptId is only accepted by SQS for FIFO queues; reject it + # early instead of letting SQS fail the receive call silently. + if request_attempt_id is not None and not queue_name.endswith(".fifo"): + msg = ( + f"`request_attempt_id` (ReceiveRequestAttemptId) is only valid for FIFO " + f"queues, but '{queue_name}' is not a FIFO queue. Use `FifoQueue(...)` " + "or a '.fifo' queue name." + ) + raise SetupError(msg) + subscriber_config = SQSSubscriberConfig( queue=queue_name, declare=queue_obj, @@ -38,6 +50,7 @@ def create_subscriber( max_messages=max_messages, visibility_timeout=visibility_timeout, batch=batch, + request_attempt_id=request_attempt_id, no_reply=no_reply, _outer_config=config, _ack_policy=ack_policy, diff --git a/faststream/sqs/subscriber/usecase.py b/faststream/sqs/subscriber/usecase.py index 37abcfb1c24..55c14a55999 100644 --- a/faststream/sqs/subscriber/usecase.py +++ b/faststream/sqs/subscriber/usecase.py @@ -49,6 +49,7 @@ def __init__( self._wait_time_seconds = config.wait_time_seconds self._max_messages = config.max_messages self._visibility_timeout = config.visibility_timeout + self._request_attempt_id = config.request_attempt_id self._queue_url: str = "" @property @@ -116,6 +117,8 @@ def _receive_kwargs(self) -> dict[str, Any]: } if self._visibility_timeout is not None: kwargs["VisibilityTimeout"] = self._visibility_timeout + if self._request_attempt_id is not None: + kwargs["ReceiveRequestAttemptId"] = self._request_attempt_id return kwargs async def _receive(self) -> list["SQSRawMessage"]: @@ -172,15 +175,19 @@ async def get_one( self._queue_url = await self._resolve_queue_url() self._sqs_parser.bind(self._outer_config.client, self._queue_url) + get_one_kwargs: dict[str, Any] = { + "QueueUrl": self._queue_url, + "MaxNumberOfMessages": 1, + "WaitTimeSeconds": min(int(timeout), 20), + "MessageAttributeNames": ["All"], + "AttributeNames": ["All"], + } + if self._request_attempt_id is not None: + get_one_kwargs["ReceiveRequestAttemptId"] = self._request_attempt_id + raw_msg: SQSRawMessage | None = None with anyio.move_on_after(timeout): - resp = await self._outer_config.client.receive_message( - QueueUrl=self._queue_url, - MaxNumberOfMessages=1, - WaitTimeSeconds=min(int(timeout), 20), - MessageAttributeNames=["All"], - AttributeNames=["All"], - ) + resp = await self._outer_config.client.receive_message(**get_one_kwargs) messages = resp.get("Messages", []) raw_msg = messages[0] if messages else None diff --git a/tests/brokers/sqs/test_features.py b/tests/brokers/sqs/test_features.py index c01618a709b..8de6f8e0981 100644 --- a/tests/brokers/sqs/test_features.py +++ b/tests/brokers/sqs/test_features.py @@ -10,6 +10,7 @@ import pytest from faststream import Context +from faststream.exceptions import SetupError from faststream.response.publish_type import PublishType from faststream.sqs import FifoQueue from faststream.sqs.exceptions import ( @@ -81,6 +82,34 @@ def test_fifo_ok_with_group_id(self) -> None: SQSFastProducer._validate_fifo(cmd) +# --------------------------------------------------------------------------- # +# ReceiveRequestAttemptId (FIFO receive-retry dedup token) +# --------------------------------------------------------------------------- # + + +class TestRequestAttemptId(SQSMemoryTestcaseConfig): + def test_passed_to_receive_kwargs(self) -> None: + broker = self.get_broker() + fifo = FifoQueue(name="orders") + + sub = broker.subscriber(fifo, request_attempt_id="attempt-1") + + assert sub._receive_kwargs()["ReceiveRequestAttemptId"] == "attempt-1" + + def test_absent_by_default(self) -> None: + broker = self.get_broker() + fifo = FifoQueue(name="orders") + + sub = broker.subscriber(fifo) + + assert "ReceiveRequestAttemptId" not in sub._receive_kwargs() + + def test_rejected_for_non_fifo_queue(self) -> None: + broker = self.get_broker() + with pytest.raises(SetupError): + broker.subscriber("plain-queue", request_attempt_id="attempt-1") + + # --------------------------------------------------------------------------- # # Parser typing & system attributes # --------------------------------------------------------------------------- # @@ -260,3 +289,30 @@ async def handler(msg: str) -> None: assert event.is_set() assert received == ["ordered"] + + async def test_fifo_request_attempt_id( + self, queue: str, event: asyncio.Event + ) -> None: + broker = self.get_broker(apply_types=True) + fifo = FifoQueue(name=f"{queue}.fifo", content_based_deduplication=True) + received: list[str] = [] + + # ReceiveRequestAttemptId is accepted by SQS only for FIFO queues; this + # verifies the kwarg is threaded through and does not break receiving. + @broker.subscriber(fifo, request_attempt_id="attempt-1") + async def handler(msg: str) -> None: + received.append(msg) + event.set() + + async with broker: + await broker.start() + await asyncio.wait( + ( + asyncio.create_task(broker.publish("ordered", fifo, group_id="g1")), + asyncio.create_task(event.wait()), + ), + timeout=self.timeout, + ) + + assert event.is_set() + assert received == ["ordered"] diff --git a/tests/docs/sqs/fifo/test_fifo.py b/tests/docs/sqs/fifo/test_fifo.py index 65420cba196..f517098ca46 100644 --- a/tests/docs/sqs/fifo/test_fifo.py +++ b/tests/docs/sqs/fifo/test_fifo.py @@ -1,3 +1,4 @@ from docs.docs_src.sqs.fifo.app import test_fifo +from docs.docs_src.sqs.fifo.request_attempt_id import test_request_attempt_id -__all__ = ["test_fifo"] +__all__ = ["test_fifo", "test_request_attempt_id"] From 65fe8cf4fdb937963839b9cba38b30fa2723057f Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Tue, 9 Jun 2026 23:59:38 +0300 Subject: [PATCH 08/19] test(sqs): migrate emulator to ElasticMQ and extend coverage --- .github/workflows/pr_tests.yaml | 16 ++++++------ docker-compose.yaml | 7 +++--- docs/includes/docker-compose.yaml | 5 ++-- tests/brokers/sqs/basic.py | 10 +++----- tests/brokers/sqs/conftest.py | 10 +++----- tests/brokers/sqs/test_consume.py | 40 +++++++++++++++++++++++++++++- tests/brokers/sqs/test_fastapi.py | 20 ++++----------- tests/brokers/sqs/test_features.py | 36 ++++----------------------- tests/brokers/sqs/test_publish.py | 36 ++++++++++++++++++++++++++- 9 files changed, 107 insertions(+), 73 deletions(-) diff --git a/.github/workflows/pr_tests.yaml b/.github/workflows/pr_tests.yaml index af2fdcbfd82..578aadb62b1 100644 --- a/.github/workflows/pr_tests.yaml +++ b/.github/workflows/pr_tests.yaml @@ -620,10 +620,15 @@ jobs: - test-basic - test-sqs-smoke services: - localstack: - image: localstack/localstack:4 + sqs: + image: softwaremill/elasticmq-native:1.7.1 ports: - - 4566:4566 + - 9324:9324 + options: >- + --health-cmd "wget --no-verbose --tries=1 --spider http://localhost:9324/health" + --health-interval 5s + --health-timeout 5s + --health-retries 20 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -639,11 +644,6 @@ jobs: run: | uv pip install --system --group optionals --group testing . - name: Test - env: - SQS_ENDPOINT_URL: "http://localhost:4566" - AWS_ACCESS_KEY_ID: "test" - AWS_SECRET_ACCESS_KEY: "test" - AWS_REGION: "us-east-1" run: > pytest --cov --cov-report= -vv -m "(slow and sqs and connected) or (sqs and connected)" diff --git a/docker-compose.yaml b/docker-compose.yaml index b288b95470c..b8d47d3026c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -81,11 +81,12 @@ services: - no-new-privileges:true sqs: - image: localstack/localstack:4 + image: softwaremill/elasticmq-native:1.7.1 ports: - - "4566:4566" + - "9324:9324" + - "9325:9325" healthcheck: - test: 'curl -s http://localhost:4566/_localstack/health | grep -q ''"sqs": "available"'' || exit 1' + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9324/health"] interval: 5s timeout: 5s retries: 20 diff --git a/docs/includes/docker-compose.yaml b/docs/includes/docker-compose.yaml index bdb1224092b..0c09dfca3d7 100644 --- a/docs/includes/docker-compose.yaml +++ b/docs/includes/docker-compose.yaml @@ -81,8 +81,9 @@ services: - no-new-privileges:true sqs: - image: localstack/localstack:4 + image: softwaremill/elasticmq-native:1.7.1 ports: - - "4566:4566" + - "9324:9324" # SQS API + - "9325:9325" # web UI security_opt: - no-new-privileges:true diff --git a/tests/brokers/sqs/basic.py b/tests/brokers/sqs/basic.py index b82365573a3..8ea94d6071e 100644 --- a/tests/brokers/sqs/basic.py +++ b/tests/brokers/sqs/basic.py @@ -1,4 +1,3 @@ -import os from typing import Any from faststream.sqs.broker.broker import SQSBroker @@ -17,12 +16,11 @@ def get_broker( apply_types: bool = False, **kwargs: Any, ) -> SQSBroker: - # LocalStack defaults; override via env for real AWS / other emulators. return SQSBroker( - endpoint_url=os.environ.get("SQS_ENDPOINT_URL", "http://localhost:4566"), - region_name=os.environ.get("AWS_REGION", "us-east-1"), - aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID", "test"), - aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY", "test"), + endpoint_url="http://localhost:9324", + region_name="us-east-1", + aws_access_key_id="test", + aws_secret_access_key="test", apply_types=apply_types, **kwargs, ) diff --git a/tests/brokers/sqs/conftest.py b/tests/brokers/sqs/conftest.py index 9779c48956c..54a34684661 100644 --- a/tests/brokers/sqs/conftest.py +++ b/tests/brokers/sqs/conftest.py @@ -1,4 +1,3 @@ -import os from dataclasses import dataclass import pytest @@ -8,11 +7,10 @@ @dataclass class Settings: - # LocalStack defaults; override via env for real AWS / other emulators. - endpoint_url: str = os.environ.get("SQS_ENDPOINT_URL", "http://localhost:4566") - region_name: str = os.environ.get("AWS_REGION", "us-east-1") - aws_access_key_id: str = os.environ.get("AWS_ACCESS_KEY_ID", "test") - aws_secret_access_key: str = os.environ.get("AWS_SECRET_ACCESS_KEY", "test") + endpoint_url: str = "http://localhost:9324" + region_name: str = "us-east-1" + aws_access_key_id: str = "test" + aws_secret_access_key: str = "test" @pytest.fixture(scope="session") diff --git a/tests/brokers/sqs/test_consume.py b/tests/brokers/sqs/test_consume.py index 1a0f1360c06..9dcc2a4a3a7 100644 --- a/tests/brokers/sqs/test_consume.py +++ b/tests/brokers/sqs/test_consume.py @@ -1,6 +1,12 @@ +import asyncio +from unittest.mock import MagicMock, patch + import pytest +from faststream import AckPolicy +from faststream.sqs.message import SQSMessage from tests.brokers.base.consume import BrokerRealConsumeTestcase +from tests.tools import spy_decorator from .basic import SQSTestcaseConfig @@ -8,4 +14,36 @@ @pytest.mark.connected() @pytest.mark.sqs() class TestConsume(SQSTestcaseConfig, BrokerRealConsumeTestcase): - pass + @pytest.mark.asyncio() + async def test_manual_ack_not_called_automatically( + self, + queue: str, + mock: MagicMock, + ) -> None: + """With ``AckPolicy.MANUAL`` the framework must not delete the message.""" + event = asyncio.Event() + + consume_broker = self.get_broker(apply_types=True) + + args, kwargs = self.get_subscriber_params(queue, ack_policy=AckPolicy.MANUAL) + + @consume_broker.subscriber(*args, **kwargs) + async def handler(body: str) -> None: + mock(body) + event.set() + + async with self.patch_broker(consume_broker) as br: + await br.start() + + with patch.object(SQSMessage, "ack", spy_decorator(SQSMessage.ack)) as m: + await asyncio.wait( + ( + asyncio.create_task(br.publish("hello", queue)), + asyncio.create_task(event.wait()), + ), + timeout=self.timeout, + ) + assert not m.mock.called + + assert event.is_set() + mock.assert_called_once_with("hello") diff --git a/tests/brokers/sqs/test_fastapi.py b/tests/brokers/sqs/test_fastapi.py index 8a50a461d89..06f443b0295 100644 --- a/tests/brokers/sqs/test_fastapi.py +++ b/tests/brokers/sqs/test_fastapi.py @@ -1,4 +1,3 @@ -import os from typing import Any import pytest @@ -11,26 +10,17 @@ class StreamRouter(_SQSStreamRouter): - """FastAPI SQS router pre-wired to LocalStack. + """FastAPI SQS router pre-wired to the local ElasticMQ emulator. The shared FastAPI test cases instantiate ``router_class()`` with no arguments, so the connection defaults have to be baked into the class. """ def __init__(self, *args: Any, **kwargs: Any) -> None: - kwargs.setdefault( - "endpoint_url", - os.environ.get("SQS_ENDPOINT_URL", "http://localhost:4566"), - ) - kwargs.setdefault("region_name", os.environ.get("AWS_REGION", "us-east-1")) - kwargs.setdefault( - "aws_access_key_id", - os.environ.get("AWS_ACCESS_KEY_ID", "test"), - ) - kwargs.setdefault( - "aws_secret_access_key", - os.environ.get("AWS_SECRET_ACCESS_KEY", "test"), - ) + kwargs.setdefault("endpoint_url", "http://localhost:9324") + kwargs.setdefault("region_name", "us-east-1") + kwargs.setdefault("aws_access_key_id", "test") + kwargs.setdefault("aws_secret_access_key", "test") super().__init__(*args, **kwargs) diff --git a/tests/brokers/sqs/test_features.py b/tests/brokers/sqs/test_features.py index 8de6f8e0981..9b8a21c038e 100644 --- a/tests/brokers/sqs/test_features.py +++ b/tests/brokers/sqs/test_features.py @@ -31,11 +31,6 @@ def _attr(value: str) -> dict[str, str]: return {"DataType": "String", "StringValue": value} -# --------------------------------------------------------------------------- # -# Producer guards -# --------------------------------------------------------------------------- # - - class TestProducerGuards: def test_chunk_entries_splits_over_ten(self) -> None: entries = [{"MessageBody": "x", "MessageAttributes": {}} for _ in range(23)] @@ -82,11 +77,6 @@ def test_fifo_ok_with_group_id(self) -> None: SQSFastProducer._validate_fifo(cmd) -# --------------------------------------------------------------------------- # -# ReceiveRequestAttemptId (FIFO receive-retry dedup token) -# --------------------------------------------------------------------------- # - - class TestRequestAttemptId(SQSMemoryTestcaseConfig): def test_passed_to_receive_kwargs(self) -> None: broker = self.get_broker() @@ -110,11 +100,6 @@ def test_rejected_for_non_fifo_queue(self) -> None: broker.subscriber("plain-queue", request_attempt_id="attempt-1") -# --------------------------------------------------------------------------- # -# Parser typing & system attributes -# --------------------------------------------------------------------------- # - - @pytest.mark.asyncio() class TestParser: async def test_binary_attribute_decoded_as_bytes(self) -> None: @@ -153,11 +138,6 @@ async def test_empty_receive_count_defaults_to_zero(self) -> None: assert msg.approximate_receive_count == 0 -# --------------------------------------------------------------------------- # -# Message ack/nack semantics -# --------------------------------------------------------------------------- # - - @pytest.mark.asyncio() class TestMessageAck: async def test_nack_default_immediate(self) -> None: @@ -192,11 +172,6 @@ async def test_batch_ack_deletes_all(self) -> None: assert {e["ReceiptHandle"] for e in kwargs["Entries"]} == {"rh0", "rh1", "rh2"} -# --------------------------------------------------------------------------- # -# Batch consumption (in-memory) -# --------------------------------------------------------------------------- # - - @pytest.mark.asyncio() class TestBatchConsume(SQSMemoryTestcaseConfig): async def test_batch_subscriber_receives_list(self, queue: str) -> None: @@ -211,11 +186,6 @@ async def handler(msg) -> None: handler.mock.assert_called_once_with(["hello"]) -# --------------------------------------------------------------------------- # -# Connected (LocalStack) coverage of the new behaviour -# --------------------------------------------------------------------------- # - - @pytest.mark.connected() @pytest.mark.sqs() @pytest.mark.asyncio() @@ -227,7 +197,11 @@ async def test_batch_consume(self, queue: str, event: asyncio.Event) -> None: @broker.subscriber(queue, batch=True) async def handler(msgs: list) -> None: received.extend(msgs) - event.set() + # SQS does not guarantee returning all available messages in a single + # ReceiveMessage call (ElasticMQ delivers them one at a time), so wait + # until the whole batch has arrived across polls before asserting. + if len(received) >= 3: + event.set() async with broker: await broker.start() diff --git a/tests/brokers/sqs/test_publish.py b/tests/brokers/sqs/test_publish.py index ee48dc3493f..de64950f62d 100644 --- a/tests/brokers/sqs/test_publish.py +++ b/tests/brokers/sqs/test_publish.py @@ -1,5 +1,10 @@ +import asyncio +from unittest.mock import MagicMock + import pytest +from faststream import Context +from faststream.sqs import SQSResponse from tests.brokers.base.publish import BrokerPublishTestcase from .basic import SQSTestcaseConfig @@ -8,4 +13,33 @@ @pytest.mark.connected() @pytest.mark.sqs() class TestPublish(SQSTestcaseConfig, BrokerPublishTestcase): - pass + @pytest.mark.asyncio() + async def test_response(self, queue: str, mock: MagicMock) -> None: + """A handler returning ``SQSResponse`` publishes it to the next queue.""" + event = asyncio.Event() + + pub_broker = self.get_broker(apply_types=True) + + @pub_broker.subscriber(queue) + @pub_broker.publisher(queue + "1") + async def handle(): + return SQSResponse(1, correlation_id="1") + + @pub_broker.subscriber(queue + "1") + async def handle_next(msg=Context("message")) -> None: + mock(body=msg.body, correlation_id=msg.correlation_id) + event.set() + + async with self.patch_broker(pub_broker) as br: + await br.start() + + await asyncio.wait( + ( + asyncio.create_task(br.publish("", queue, correlation_id="wrong")), + asyncio.create_task(event.wait()), + ), + timeout=self.timeout, + ) + + assert event.is_set() + mock.assert_called_once_with(body=b"1", correlation_id="1") From bf7baa4fead327660c0db281bf94e929b2b38b62 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Wed, 10 Jun 2026 23:30:10 +0300 Subject: [PATCH 09/19] fix(sqs): address pre-PR review findings - restore non-UTF-8 publishing: bodies are base64-encoded with a reserved `base64-body` attribute instead of crashing with UnicodeDecodeError - make `system_attributes` an instance attribute (was a shared class-level dict) and populate it for batch messages (+ per-message `batch_system_attributes`); `decode_batch` reuses the parses - `get_one`: build kwargs via `_receive_kwargs()` (restores VisibilityTimeout) and keep WaitTimeSeconds strictly below the client deadline so near-deadline messages are not lost into the visibility window - share one poll-with-backoff helper between the consume loop, `__aiter__` (was a silent busy-spin via `suppress`) and the RPC response loop (which now also deletes replies with one `delete_message_batch`) - `ping()` honors its `timeout` via `anyio.move_on_after` - FastAPI `SQSRouter.subscriber` exposes the `batch` parameter - batch-aware Prometheus/OTel providers (size in bytes + real message count) - `publish_batch` honors user codecs implementing `BatchCodecProto` - default `wait_time_seconds` 5 -> 20 (AWS long-polling guidance) - test cleanup: FakeProducer reuses the parent constructor, fake connect goes through `SQSBrokerConfig.connect`, ElasticMQ settings deduplicated Co-Authored-By: Claude Fable 5 --- docs/docs/en/sqs/publishing.md | 12 +- faststream/sqs/broker/broker.py | 67 +++++----- faststream/sqs/broker/registrator.py | 2 +- faststream/sqs/broker/router.py | 2 +- faststream/sqs/fastapi/fastapi.py | 4 +- faststream/sqs/helpers.py | 33 +++++ faststream/sqs/message.py | 19 ++- faststream/sqs/opentelemetry/middleware.py | 6 +- faststream/sqs/opentelemetry/provider.py | 60 +++++++-- faststream/sqs/parser.py | 52 +++++--- faststream/sqs/prometheus/middleware.py | 4 +- faststream/sqs/prometheus/provider.py | 43 ++++++- faststream/sqs/publisher/producer.py | 62 ++++++--- faststream/sqs/subscriber/config.py | 2 +- faststream/sqs/subscriber/usecase.py | 114 ++++++++-------- faststream/sqs/testing.py | 67 +++++++--- tests/brokers/sqs/basic.py | 14 +- tests/brokers/sqs/conftest.py | 10 +- tests/brokers/sqs/test_fastapi.py | 17 ++- tests/brokers/sqs/test_features.py | 143 ++++++++++++++++++++- tests/mypy/sqs.py | 23 ++++ tests/opentelemetry/sqs/test_provider.py | 76 +++++++++++ tests/prometheus/sqs/basic.py | 10 +- tests/prometheus/sqs/test_provider.py | 71 +++++++++- 24 files changed, 725 insertions(+), 188 deletions(-) create mode 100644 faststream/sqs/helpers.py create mode 100644 tests/mypy/sqs.py create mode 100644 tests/opentelemetry/sqs/test_provider.py diff --git a/docs/docs/en/sqs/publishing.md b/docs/docs/en/sqs/publishing.md index 0f9cafeee28..0bce776770d 100644 --- a/docs/docs/en/sqs/publishing.md +++ b/docs/docs/en/sqs/publishing.md @@ -7,8 +7,16 @@ Publish to a queue with `broker.publish`: ``` Headers are sent as SQS **MessageAttributes**. FastStream reserves the -`content-type`, `reply_to`, and `correlation_id` attribute names for transport -metadata. +`content-type`, `reply_to`, `correlation_id`, `empty-body`, and `base64-body` +attribute names for transport metadata. + +!!! note "Binary payloads" + SQS accepts only text message bodies. When you publish `bytes` that are not + valid UTF-8 (an image, gzip, protobuf, ...), FastStream sends them + base64-encoded and sets the reserved `base64-body` message attribute, so a + FastStream subscriber receives the original bytes back transparently. + Non-FastStream consumers of such messages should base64-decode the body when + that attribute is present. ## Declared queues diff --git a/faststream/sqs/broker/broker.py b/faststream/sqs/broker/broker.py index c00d6b09880..8919ec266b0 100644 --- a/faststream/sqs/broker/broker.py +++ b/faststream/sqs/broker/broker.py @@ -4,8 +4,8 @@ from contextlib import suppress from typing import TYPE_CHECKING, Any, Optional, Union, cast +import anyio from aiobotocore.session import get_session -from botocore.exceptions import BotoCoreError, ClientError from fast_depends import Provider, dependency_provider from typing_extensions import override @@ -18,6 +18,7 @@ from faststream.response.publish_type import PublishType from faststream.specification.schema import BrokerSpec from faststream.sqs.configs import SQSBrokerConfig +from faststream.sqs.helpers import poll_with_backoff from faststream.sqs.message import SQSRawMessage from faststream.sqs.publisher.producer import SQSFastProducer from faststream.sqs.response import SQSBatchPublishCommand, SQSPublishCommand @@ -169,34 +170,40 @@ async def _setup_response_queue(self) -> None: async def _consume_responses(self, queue_url: str) -> None: client = self.config.client producer = cast("SQSFastProducer", self.config.producer) - backoff = 1.0 - while self.running: - try: - resp = await client.receive_message( - QueueUrl=queue_url, - MaxNumberOfMessages=10, - WaitTimeSeconds=5, - MessageAttributeNames=["All"], - ) - except (ClientError, BotoCoreError) as e: - # Don't busy-loop silently on e.g. bad credentials; log and back off. - self.config.logger.log( - f"SQS response-queue receive failed, retrying in {backoff:.0f}s: {e}", - logging.WARNING, - ) - await asyncio.sleep(backoff) - backoff = min(backoff * 2, 30.0) - continue - backoff = 1.0 - for message in resp.get("Messages", []): + async def receive() -> list[Any]: + resp = await client.receive_message( + QueueUrl=queue_url, + MaxNumberOfMessages=10, + WaitTimeSeconds=20, + MessageAttributeNames=["All"], + ) + return list(resp.get("Messages", [])) + + def log_error(error: Exception, backoff: float) -> None: + # Don't busy-loop silently on e.g. bad credentials; log and back off. + self.config.logger.log( + f"SQS response-queue receive failed, retrying in {backoff:.0f}s: {error}", + logging.WARNING, + ) + + async for messages in poll_with_backoff( + receive, + is_running=lambda: self.running, + on_error=log_error, + ): + for message in messages: attrs = message.get("MessageAttributes", {}) or {} cid = attrs.get("correlation_id", {}).get("StringValue", "") if cid: producer.resolve_response(cid, message) - await client.delete_message( + if messages: + await client.delete_message_batch( QueueUrl=queue_url, - ReceiptHandle=message["ReceiptHandle"], + Entries=[ + {"Id": str(i), "ReceiptHandle": m["ReceiptHandle"]} + for i, m in enumerate(messages) + ], ) @override @@ -235,12 +242,14 @@ async def stop( async def ping(self, timeout: float | None = None) -> bool: if self._connection is None: return False - try: - await self._connection.list_queues(MaxResults=1) - except Exception: - return False - else: - return True + with anyio.move_on_after(timeout): + try: + await self._connection.list_queues(MaxResults=1) + except Exception: + return False + else: + return True + return False @override async def publish( diff --git a/faststream/sqs/broker/registrator.py b/faststream/sqs/broker/registrator.py index 4e0b32ee90e..fa30131fb4a 100644 --- a/faststream/sqs/broker/registrator.py +++ b/faststream/sqs/broker/registrator.py @@ -30,7 +30,7 @@ def subscriber( # type: ignore[override] self, queue: Union[str, "SQSQueue"], *, - wait_time_seconds: int = 5, + wait_time_seconds: int = 20, max_messages: int = 10, visibility_timeout: int | None = None, batch: bool = False, diff --git a/faststream/sqs/broker/router.py b/faststream/sqs/broker/router.py index 9980ffea509..ad06019b57d 100644 --- a/faststream/sqs/broker/router.py +++ b/faststream/sqs/broker/router.py @@ -61,7 +61,7 @@ def __init__( queue: Union[str, "SQSQueue"], *, publishers: Iterable["SQSPublisher"] = (), - wait_time_seconds: int = 5, + wait_time_seconds: int = 20, max_messages: int = 10, visibility_timeout: int | None = None, ack_policy: AckPolicy = EMPTY, diff --git a/faststream/sqs/fastapi/fastapi.py b/faststream/sqs/fastapi/fastapi.py index 7b832291e39..97d482b54f8 100644 --- a/faststream/sqs/fastapi/fastapi.py +++ b/faststream/sqs/fastapi/fastapi.py @@ -152,9 +152,10 @@ def subscriber( # type: ignore[override] self, queue: Union[str, "SQSQueue"], *, - wait_time_seconds: int = 5, + wait_time_seconds: int = 20, max_messages: int = 10, visibility_timeout: int | None = None, + batch: bool = False, request_attempt_id: str | None = None, # broker arguments ack_policy: AckPolicy = EMPTY, @@ -184,6 +185,7 @@ def subscriber( # type: ignore[override] wait_time_seconds=wait_time_seconds, max_messages=max_messages, visibility_timeout=visibility_timeout, + batch=batch, request_attempt_id=request_attempt_id, ack_policy=ack_policy, no_reply=no_reply, diff --git a/faststream/sqs/helpers.py b/faststream/sqs/helpers.py new file mode 100644 index 00000000000..6ee989c13f1 --- /dev/null +++ b/faststream/sqs/helpers.py @@ -0,0 +1,33 @@ +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import TypeVar + +import anyio +from botocore.exceptions import BotoCoreError, ClientError + +T = TypeVar("T") + + +async def poll_with_backoff( + receive: Callable[[], Awaitable[T]], + *, + is_running: Callable[[], bool], + on_error: Callable[[Exception, float], None], + max_backoff: float = 30.0, +) -> AsyncIterator[T]: + """Yield SQS receive results in a loop, backing off exponentially on errors. + + Transient client errors (bad credentials, deleted queue, network issues) + are reported via ``on_error`` and retried with a growing delay instead of + busy-spinning against the (billable) SQS API. + """ + backoff = 1.0 + while is_running(): + try: + result = await receive() + except (ClientError, BotoCoreError) as e: + on_error(e, backoff) + await anyio.sleep(backoff) + backoff = min(backoff * 2, max_backoff) + continue + backoff = 1.0 + yield result diff --git a/faststream/sqs/message.py b/faststream/sqs/message.py index a9518c0fa0a..c3d58a2187d 100644 --- a/faststream/sqs/message.py +++ b/faststream/sqs/message.py @@ -24,8 +24,11 @@ class SQSMessage(StreamMessage["SQSRawMessage"]): # Set by SQSParser at parse time so ack/nack/reject can reach the queue. sqs_client: "SQSClient | None" = None queue_url: str = "" - # SQS system attributes (Attributes), set by SQSParser at parse time. - system_attributes: dict[str, str] = {} # noqa: RUF012 + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # SQS system attributes (Attributes), set by SQSParser at parse time. + self.system_attributes: dict[str, str] = {} @property def receipt_handle(self) -> str: @@ -92,8 +95,20 @@ class SQSBatchMessage(SQSMessage): ``raw_message`` is the list of raw SQS messages; ack/nack/reject act on all of them at once using the SQS batch APIs (chunked to the 10-entry limit). + + ``system_attributes`` (and the scalar properties derived from it) reflect the + first message of the batch — the same "first message wins" convention used + for ``content_type``/``correlation_id``; ``batch_system_attributes`` keeps + the per-message attributes. """ + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # Per-message SQS system attributes, set by SQSParser at parse time. + self.batch_system_attributes: list[dict[str, str]] = [] + # Per-message parses kept by SQSParser so decode_batch needn't re-parse. + self.parsed_messages: list[SQSMessage] = [] + @property def receipt_handles(self) -> list[str]: messages = cast("list[Any]", self.raw_message) diff --git a/faststream/sqs/opentelemetry/middleware.py b/faststream/sqs/opentelemetry/middleware.py index dfc0d1217f4..8e1eaeb6581 100644 --- a/faststream/sqs/opentelemetry/middleware.py +++ b/faststream/sqs/opentelemetry/middleware.py @@ -2,7 +2,9 @@ from opentelemetry.trace import TracerProvider from faststream.opentelemetry.middleware import TelemetryMiddleware -from faststream.sqs.opentelemetry.provider import SQSTelemetrySettingsProvider +from faststream.sqs.opentelemetry.provider import ( + telemetry_attributes_provider_factory, +) from faststream.sqs.response import SQSPublishCommand @@ -16,7 +18,7 @@ def __init__( include_messages_counters: bool = True, ) -> None: super().__init__( - settings_provider_factory=lambda _: SQSTelemetrySettingsProvider(), + settings_provider_factory=telemetry_attributes_provider_factory, tracer_provider=tracer_provider, meter_provider=meter_provider, meter=meter, diff --git a/faststream/sqs/opentelemetry/provider.py b/faststream/sqs/opentelemetry/provider.py index 42810f53cf3..eff036759ec 100644 --- a/faststream/sqs/opentelemetry/provider.py +++ b/faststream/sqs/opentelemetry/provider.py @@ -1,24 +1,45 @@ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from opentelemetry.semconv.trace import SpanAttributes +from faststream._internal.types import MsgType from faststream.opentelemetry import TelemetrySettingsProvider from faststream.opentelemetry.consts import MESSAGING_DESTINATION_PUBLISH_NAME from faststream.sqs.message import SQSRawMessage from faststream.sqs.response import SQSPublishCommand if TYPE_CHECKING: + from collections.abc import Sequence + from faststream.message import StreamMessage -class SQSTelemetrySettingsProvider( - TelemetrySettingsProvider[SQSRawMessage, SQSPublishCommand], +class BaseSQSTelemetrySettingsProvider( + TelemetrySettingsProvider[MsgType, SQSPublishCommand], ): __slots__ = ("messaging_system",) def __init__(self) -> None: self.messaging_system = "aws_sqs" + def get_publish_attrs_from_cmd( + self, + cmd: SQSPublishCommand, + ) -> dict[str, Any]: + return { + SpanAttributes.MESSAGING_SYSTEM: self.messaging_system, + SpanAttributes.MESSAGING_DESTINATION_NAME: cmd.destination, + SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: cmd.correlation_id, + } + + def get_publish_destination_name( + self, + cmd: SQSPublishCommand, + ) -> str: + return cmd.destination + + +class SQSTelemetrySettingsProvider(BaseSQSTelemetrySettingsProvider[SQSRawMessage]): def get_consume_attrs_from_message( self, msg: "StreamMessage[SQSRawMessage]", @@ -37,18 +58,37 @@ def get_consume_destination_name( ) -> str: return getattr(msg, "queue_url", "") - def get_publish_attrs_from_cmd( + +class BatchSQSTelemetrySettingsProvider( + BaseSQSTelemetrySettingsProvider[list[SQSRawMessage]], +): + def get_consume_attrs_from_message( self, - cmd: SQSPublishCommand, + msg: "StreamMessage[list[SQSRawMessage]]", ) -> dict[str, Any]: + # an SQSBatchMessage body is the list of the per-message bodies + bodies = cast("Sequence[bytes]", msg.body) return { SpanAttributes.MESSAGING_SYSTEM: self.messaging_system, - SpanAttributes.MESSAGING_DESTINATION_NAME: cmd.destination, - SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: cmd.correlation_id, + SpanAttributes.MESSAGING_MESSAGE_ID: msg.message_id, + SpanAttributes.MESSAGING_MESSAGE_CONVERSATION_ID: msg.correlation_id, + SpanAttributes.MESSAGING_MESSAGE_PAYLOAD_SIZE_BYTES: sum( + len(body) for body in bodies + ), + SpanAttributes.MESSAGING_BATCH_MESSAGE_COUNT: len(msg.raw_message), + MESSAGING_DESTINATION_PUBLISH_NAME: getattr(msg, "queue_url", ""), } - def get_publish_destination_name( + def get_consume_destination_name( self, - cmd: SQSPublishCommand, + msg: "StreamMessage[list[SQSRawMessage]]", ) -> str: - return cmd.destination + return getattr(msg, "queue_url", "") + + +def telemetry_attributes_provider_factory( + msg: SQSRawMessage | list[SQSRawMessage] | None, +) -> SQSTelemetrySettingsProvider | BatchSQSTelemetrySettingsProvider: + if isinstance(msg, list): + return BatchSQSTelemetrySettingsProvider() + return SQSTelemetrySettingsProvider() diff --git a/faststream/sqs/parser.py b/faststream/sqs/parser.py index 31b7d6b535d..fca785b71f6 100644 --- a/faststream/sqs/parser.py +++ b/faststream/sqs/parser.py @@ -1,3 +1,4 @@ +import base64 from typing import TYPE_CHECKING, Any, cast from faststream.message import StreamMessage, decode_message @@ -17,8 +18,18 @@ EMPTY_BODY_ATTR = "empty-body" EMPTY_BODY_PLACEHOLDER = " " +# SQS accepts only text bodies; non-UTF-8 payloads are sent base64-encoded and +# flagged with this reserved attribute so the parser can restore the raw bytes. +BASE64_BODY_ATTR = "base64-body" + # Message attribute names FastStream reserves for transport metadata. -RESERVED_ATTRS = ("content-type", "reply_to", "correlation_id", EMPTY_BODY_ATTR) +RESERVED_ATTRS = ( + "content-type", + "reply_to", + "correlation_id", + EMPTY_BODY_ATTR, + BASE64_BODY_ATTR, +) class SQSParser: @@ -56,6 +67,7 @@ async def parse_message(self, message: "SQSRawMessage") -> SQSMessage: reply_to: str = "" correlation_id: str | None = None empty_body = False + base64_body = False for name, attr in attributes.items(): value = self._attr_value(attr) @@ -67,6 +79,8 @@ async def parse_message(self, message: "SQSRawMessage") -> SQSMessage: correlation_id = value elif name == EMPTY_BODY_ATTR: empty_body = True + elif name == BASE64_BODY_ATTR: + base64_body = True else: headers[name] = value @@ -74,11 +88,11 @@ async def parse_message(self, message: "SQSRawMessage") -> SQSMessage: raw_body = body.encode() if isinstance(body, str) else (body or b"") if empty_body: raw_body = b"" + elif base64_body: + raw_body = base64.b64decode(raw_body) # SQS system attributes (ApproximateReceiveCount, MessageGroupId, ...) - system_attributes = cast( - "dict[str, str]", message.get("Attributes", {}) or {} - ) + system_attributes = cast("dict[str, str]", message.get("Attributes", {}) or {}) parsed = SQSMessage( raw_message=message, @@ -108,33 +122,35 @@ async def parse_batch( """ bodies: list[Any] = [] batch_headers: list[dict[str, Any]] = [] - content_type: str | None = None - reply_to = "" - correlation_id: str | None = None + singles: list[SQSMessage] = [] - for idx, message in enumerate(messages): + for message in messages: single = await self.parse_message(message) + singles.append(single) bodies.append(single.body) batch_headers.append(single.headers) - if idx == 0: - content_type = single.content_type - reply_to = single.reply_to - correlation_id = single.correlation_id - first = messages[0] if messages else {} + first = singles[0] if singles else None parsed = SQSBatchMessage( raw_message=cast("Any", messages), body=bodies, headers=batch_headers[0] if batch_headers else {}, batch_headers=batch_headers, - content_type=content_type, - reply_to=reply_to, - correlation_id=correlation_id, - message_id=first.get("MessageId"), + content_type=first.content_type if first else None, + reply_to=first.reply_to if first else "", + correlation_id=first.correlation_id if first else None, + message_id=first.message_id if first else None, ) parsed.sqs_client = self.client parsed.queue_url = self.queue_url + parsed.system_attributes = first.system_attributes if first else {} + parsed.batch_system_attributes = [s.system_attributes for s in singles] + # Keep the per-message parses so decode_batch doesn't re-parse the batch. + parsed.parsed_messages = singles return parsed async def decode_batch(self, msg: "StreamMessage[Any]") -> "DecodedMessage": - return [decode_message(await self.parse_message(m)) for m in msg.raw_message] + singles: list[SQSMessage] = getattr(msg, "parsed_messages", []) + if not singles: # a batch built outside parse_batch + singles = [await self.parse_message(m) for m in msg.raw_message] + return [decode_message(single) for single in singles] diff --git a/faststream/sqs/prometheus/middleware.py b/faststream/sqs/prometheus/middleware.py index 5981ea3b25c..11c399f7196 100644 --- a/faststream/sqs/prometheus/middleware.py +++ b/faststream/sqs/prometheus/middleware.py @@ -4,7 +4,7 @@ from faststream._internal.constants import EMPTY from faststream.prometheus.middleware import PrometheusMiddleware from faststream.sqs.message import SQSRawMessage -from faststream.sqs.prometheus.provider import SQSMetricsSettingsProvider +from faststream.sqs.prometheus.provider import settings_provider_factory from faststream.sqs.response import SQSPublishCommand if TYPE_CHECKING: @@ -24,7 +24,7 @@ def __init__( custom_labels: dict[str, str | Callable[[Any], str]] | None = None, ) -> None: super().__init__( - settings_provider_factory=lambda _: SQSMetricsSettingsProvider(), + settings_provider_factory=settings_provider_factory, # type: ignore[arg-type] registry=registry, app_name=app_name, metrics_prefix=metrics_prefix, diff --git a/faststream/sqs/prometheus/provider.py b/faststream/sqs/prometheus/provider.py index eab590908eb..033d3bc2201 100644 --- a/faststream/sqs/prometheus/provider.py +++ b/faststream/sqs/prometheus/provider.py @@ -1,21 +1,32 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast +from faststream.message.message import MsgType from faststream.prometheus import ConsumeAttrs, MetricsSettingsProvider from faststream.sqs.message import SQSRawMessage from faststream.sqs.response import SQSPublishCommand if TYPE_CHECKING: + from collections.abc import Sequence + from faststream.message.message import StreamMessage -class SQSMetricsSettingsProvider( - MetricsSettingsProvider[SQSRawMessage, SQSPublishCommand], +class BaseSQSMetricsSettingsProvider( + MetricsSettingsProvider[MsgType, SQSPublishCommand], ): __slots__ = ("messaging_system",) def __init__(self) -> None: self.messaging_system = "aws_sqs" + def get_publish_destination_name_from_cmd( + self, + cmd: SQSPublishCommand, + ) -> str: + return cmd.destination + + +class SQSMetricsSettingsProvider(BaseSQSMetricsSettingsProvider[SQSRawMessage]): def get_consume_attrs_from_message( self, msg: "StreamMessage[SQSRawMessage]", @@ -26,8 +37,26 @@ def get_consume_attrs_from_message( "messages_count": 1, } - def get_publish_destination_name_from_cmd( + +class BatchSQSMetricsSettingsProvider( + BaseSQSMetricsSettingsProvider[list[SQSRawMessage]], +): + def get_consume_attrs_from_message( self, - cmd: SQSPublishCommand, - ) -> str: - return cmd.destination + msg: "StreamMessage[list[SQSRawMessage]]", + ) -> ConsumeAttrs: + # an SQSBatchMessage body is the list of the per-message bodies + bodies = cast("Sequence[bytes]", msg.body) + return { + "destination_name": getattr(msg, "queue_url", ""), + "message_size": sum(len(body) for body in bodies), + "messages_count": len(msg.raw_message), + } + + +def settings_provider_factory( + msg: SQSRawMessage | list[SQSRawMessage] | None, +) -> SQSMetricsSettingsProvider | BatchSQSMetricsSettingsProvider: + if isinstance(msg, list): + return BatchSQSMetricsSettingsProvider() + return SQSMetricsSettingsProvider() diff --git a/faststream/sqs/publisher/producer.py b/faststream/sqs/publisher/producer.py index 4cf0dea7174..991ee7b20d0 100644 --- a/faststream/sqs/publisher/producer.py +++ b/faststream/sqs/publisher/producer.py @@ -1,4 +1,5 @@ import asyncio +import base64 from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Optional, cast @@ -6,7 +7,7 @@ from typing_extensions import override from faststream._internal.endpoint.utils import ParserComposition -from faststream._internal.parser import DefaultCodec +from faststream._internal.parser import BatchCodecProto, DefaultCodec from faststream._internal.producer import ProducerProto from faststream.exceptions import FeatureNotSupportedException, IncorrectState from faststream.message import gen_cor_id @@ -19,7 +20,12 @@ MessageTooLargeError, TooManyMessageAttributesError, ) -from faststream.sqs.parser import EMPTY_BODY_ATTR, EMPTY_BODY_PLACEHOLDER, SQSParser +from faststream.sqs.parser import ( + BASE64_BODY_ATTR, + EMPTY_BODY_ATTR, + EMPTY_BODY_PLACEHOLDER, + SQSParser, +) from faststream.sqs.response import SQSPublishCommand if TYPE_CHECKING: @@ -160,18 +166,40 @@ def _validate_fifo(cmd: "SQSPublishCommand") -> None: ) raise FifoQueueError(msg) - async def _build_send_kwargs(self, cmd: "SQSPublishCommand") -> dict[str, Any]: - self._validate_fifo(cmd) - payload, content_type = await self.codec.encode(cmd.body, self.serializer) - body = payload.decode() if isinstance(payload, bytes) else str(payload) + def _build_entry( + self, + payload: Any, + content_type: str | None, + cmd: "SQSPublishCommand", + ) -> tuple[str, dict[str, Any]]: + """Turn an encoded payload into a validated (MessageBody, attributes) pair. + + SQS accepts only text bodies, so non-UTF-8 bytes are shipped base64-encoded + and flagged with a reserved attribute (restored by ``SQSParser``); empty + bodies are replaced with a flagged placeholder the same way. + """ attrs = self._build_message_attributes(cmd, content_type) - # SQS rejects empty bodies; send a placeholder and flag it for the parser. + if isinstance(payload, bytes): + try: + body = payload.decode() + except UnicodeDecodeError: + body = base64.b64encode(payload).decode() + attrs[BASE64_BODY_ATTR] = {"DataType": "String", "StringValue": "1"} + else: + body = str(payload) + if not body: body = EMPTY_BODY_PLACEHOLDER attrs[EMPTY_BODY_ATTR] = {"DataType": "String", "StringValue": "1"} self._validate_message(body, attrs) + return body, attrs + + async def _build_send_kwargs(self, cmd: "SQSPublishCommand") -> dict[str, Any]: + self._validate_fifo(cmd) + payload, content_type = await self.codec.encode(cmd.body, self.serializer) + body, attrs = self._build_entry(payload, content_type, cmd) kwargs: dict[str, Any] = { "QueueUrl": await self.get_queue_url(cmd.queue), @@ -196,15 +224,19 @@ async def publish_batch(self, cmd: "SQSPublishCommand") -> Any: self._validate_fifo(cmd) url = await self.get_queue_url(cmd.queue) + if isinstance(self.codec, BatchCodecProto): + encoded_batch = await self.codec.encode_batch( + cmd.batch_bodies, self.serializer + ) + else: + encoded_batch = [ + await self.codec.encode(body, self.serializer) + for body in cmd.batch_bodies + ] + entries: list[dict[str, Any]] = [] - for idx, body in enumerate(cmd.batch_bodies): - payload, content_type = await self.codec.encode(body, self.serializer) - text = payload.decode() if isinstance(payload, bytes) else str(payload) - attrs = self._build_message_attributes(cmd, content_type) - if not text: - text = EMPTY_BODY_PLACEHOLDER - attrs[EMPTY_BODY_ATTR] = {"DataType": "String", "StringValue": "1"} - self._validate_message(text, attrs) + for idx, (payload, content_type) in enumerate(encoded_batch): + text, attrs = self._build_entry(payload, content_type, cmd) entry: dict[str, Any] = { "Id": str(idx), "MessageBody": text, diff --git a/faststream/sqs/subscriber/config.py b/faststream/sqs/subscriber/config.py index a4eea7393d0..64182e04068 100644 --- a/faststream/sqs/subscriber/config.py +++ b/faststream/sqs/subscriber/config.py @@ -24,7 +24,7 @@ class SQSSubscriberConfig(SubscriberUsecaseConfig): queue: str declare: "SQSQueue" - wait_time_seconds: int = 5 + wait_time_seconds: int = 20 max_messages: int = 10 visibility_timeout: int | None = None batch: bool = False diff --git a/faststream/sqs/subscriber/usecase.py b/faststream/sqs/subscriber/usecase.py index 55c14a55999..fc3546708b3 100644 --- a/faststream/sqs/subscriber/usecase.py +++ b/faststream/sqs/subscriber/usecase.py @@ -1,14 +1,13 @@ from collections.abc import AsyncIterator, Sequence -from contextlib import suppress from typing import TYPE_CHECKING, Any import anyio -from botocore.exceptions import BotoCoreError, ClientError from typing_extensions import override from faststream._internal.endpoint.subscriber import SubscriberUsecase from faststream._internal.endpoint.subscriber.mixins import TasksMixin from faststream._internal.endpoint.utils import process_msg +from faststream.sqs.helpers import poll_with_backoff from faststream.sqs.parser import SQSParser from faststream.sqs.publisher.fake import SQSFakePublisher @@ -36,12 +35,7 @@ def __init__( ) -> None: self._sqs_parser = SQSParser() self._batch = config.batch - if config.batch: - config.parser = self._sqs_parser.parse_batch - config.decoder = self._sqs_parser.decode_batch - else: - config.parser = self._sqs_parser.parse_message - config.decoder = self._sqs_parser.decode_message + config.parser, config.decoder = self._parser_pair() super().__init__(config, specification, calls) self._queue = config.queue @@ -83,23 +77,28 @@ def get_log_context( ) -> dict[str, str]: return self.build_log_context(message=message, queue=self.queue) + def _parser_pair(self) -> tuple[Any, Any]: + """The (parser, decoder) pair matching this subscriber's batch mode.""" + if self._batch: + return self._sqs_parser.parse_batch, self._sqs_parser.decode_batch + return self._sqs_parser.parse_message, self._sqs_parser.decode_message + async def _resolve_queue_url(self) -> str: if self._declare.declare: return await self._outer_config.declare_queue(self._declare, name=self.queue) return await self._outer_config.get_queue_url(self.queue) + async def _bind_queue(self) -> None: + if not self._queue_url: + self._queue_url = await self._resolve_queue_url() + self._sqs_parser.bind(self._outer_config.client, self._queue_url) + @override async def start(self) -> None: await super().start() - self._queue_url = await self._resolve_queue_url() - self._sqs_parser.bind(self._outer_config.client, self._queue_url) - if self._batch: - self._parser = self._sqs_parser.parse_batch - self._decoder = self._sqs_parser.decode_batch - else: - self._parser = self._sqs_parser.parse_message - self._decoder = self._sqs_parser.decode_message + await self._bind_queue() + self._parser, self._decoder = self._parser_pair() if self.calls: self.add_task(self._consume_loop) @@ -125,21 +124,18 @@ async def _receive(self) -> list["SQSRawMessage"]: resp = await self._outer_config.client.receive_message(**self._receive_kwargs()) return resp.get("Messages", []) - async def _consume_loop(self) -> None: - backoff = 1.0 - while self.running: - try: - messages = await self._receive() - except (ClientError, BotoCoreError) as e: - self._outer_config.logger.log( - f"SQS receive failed, retrying in {backoff:.0f}s: {e}", - extra=self.get_log_context(None), - ) - await anyio.sleep(backoff) - backoff = min(backoff * 2, 30.0) - continue + def _log_receive_error(self, error: Exception, backoff: float) -> None: + self._outer_config.logger.log( + f"SQS receive failed, retrying in {backoff:.0f}s: {error}", + extra=self.get_log_context(None), + ) - backoff = 1.0 + async def _consume_loop(self) -> None: + async for messages in poll_with_backoff( + self._receive, + is_running=lambda: self.running, + on_error=self._log_receive_error, + ): await self._dispatch(messages) async def _dispatch(self, messages: list["SQSRawMessage"]) -> None: @@ -171,19 +167,17 @@ async def get_one( "You can't use `get_one` method if subscriber has registered handlers." ) - if not self._queue_url: - self._queue_url = await self._resolve_queue_url() - self._sqs_parser.bind(self._outer_config.client, self._queue_url) + await self._bind_queue() - get_one_kwargs: dict[str, Any] = { - "QueueUrl": self._queue_url, + get_one_kwargs = self._receive_kwargs() | { "MaxNumberOfMessages": 1, - "WaitTimeSeconds": min(int(timeout), 20), - "MessageAttributeNames": ["All"], - "AttributeNames": ["All"], + # Strictly shorter than the `move_on_after` deadline below: if the + # long poll lasted the full client timeout, a message returned at + # its very end would be cancelled client-side after SQS already + # marked it in-flight — silently hiding it for the queue's whole + # visibility timeout. + "WaitTimeSeconds": min(max(int(timeout) - 1, 0), 20), } - if self._request_attempt_id is not None: - get_one_kwargs["ReceiveRequestAttemptId"] = self._request_attempt_id raw_msg: SQSRawMessage | None = None with anyio.move_on_after(timeout): @@ -204,26 +198,26 @@ async def get_one( @override async def __aiter__(self) -> AsyncIterator["SQSMessage"]: # type: ignore[override] - if not self._queue_url: - self._queue_url = await self._resolve_queue_url() - self._sqs_parser.bind(self._outer_config.client, self._queue_url) + await self._bind_queue() context = self._outer_config.fd_config.context async_parser, async_decoder = self._get_parser_and_decoder() - while True: - with suppress(ClientError, BotoCoreError): - received = await self._receive() - # In batch mode each poll result is yielded as one batch message. - inputs: list[Any] = [received] if self._batch else list(received) - for raw_msg in inputs: - if self._batch and not raw_msg: - continue - msg: SQSMessage = await process_msg( # type: ignore[assignment] - msg=raw_msg, - middlewares=( - m(raw_msg, context=context) for m in self._broker_middlewares - ), - parser=async_parser, - decoder=async_decoder, - ) - yield msg + async for received in poll_with_backoff( + self._receive, + is_running=lambda: True, + on_error=self._log_receive_error, + ): + # In batch mode each poll result is yielded as one batch message. + inputs: list[Any] = [received] if self._batch else list(received) + for raw_msg in inputs: + if self._batch and not raw_msg: + continue + msg: SQSMessage = await process_msg( # type: ignore[assignment] + msg=raw_msg, + middlewares=( + m(raw_msg, context=context) for m in self._broker_middlewares + ), + parser=async_parser, + decoder=async_decoder, + ) + yield msg diff --git a/faststream/sqs/testing.py b/faststream/sqs/testing.py index 3dc953091f7..acd0a8e595a 100644 --- a/faststream/sqs/testing.py +++ b/faststream/sqs/testing.py @@ -1,19 +1,19 @@ import asyncio +import base64 from collections.abc import Iterable, Iterator, Sequence from contextlib import contextmanager -from typing import TYPE_CHECKING, Any, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, cast, overload from unittest.mock import AsyncMock, MagicMock import anyio from typing_extensions import override -from faststream._internal.endpoint.utils import ParserComposition from faststream._internal.parser import DefaultCodec -from faststream._internal.testing.broker import TestBroker, change_producer +from faststream._internal.testing.broker import EnterType, TestBroker, change_producer from faststream.exceptions import SubscriberNotFound from faststream.message import gen_cor_id from faststream.sqs.broker.broker import SQSBroker -from faststream.sqs.parser import SQSParser +from faststream.sqs.parser import BASE64_BODY_ATTR from faststream.sqs.publisher.producer import SQSFastProducer from faststream.sqs.response import SQSPublishCommand @@ -32,13 +32,43 @@ def _queue_matches(handler_queue: str, destination: str) -> bool: return handler_queue.removesuffix(".fifo") == destination.removesuffix(".fifo") -class TestSQSBroker(TestBroker[SQSBroker]): +class TestSQSBroker(TestBroker[SQSBroker, EnterType]): """In-memory test double for SQSBroker. Routes published messages directly to matching subscribers (by queue name) without any AWS connection. """ + # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker + # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the + # AST-based ``connect_only`` detection in ``TestBroker`` relies on. + if TYPE_CHECKING: + + @overload + def __init__( + self: "TestSQSBroker[SQSBroker]", + broker: SQSBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestSQSBroker[tuple[SQSBroker, ...]]", + *brokers: SQSBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: SQSBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + def create_publisher_fake_subscriber( self, broker: SQSBroker, @@ -88,7 +118,8 @@ async def _idle(**_: Any) -> dict[str, Any]: fake_client.change_message_visibility = AsyncMock(return_value={}) fake_client.change_message_visibility_batch = AsyncMock(return_value={}) - broker.config.broker_config._client = fake_client + # The same wiring hook the real SQSBroker._connect uses. + broker.config.connect(fake_client) return fake_client @@ -100,17 +131,9 @@ def __init__( broker: SQSBroker, brokers: Sequence[SQSBroker], ) -> None: + super().__init__(parser=broker._parser, decoder=broker._decoder) self.broker = broker self.brokers = brokers - self.serializer: SerializerProto | None = None - self._client = None - self._queue_urls = {} - self.response_queue_url = None - self._futures = {} - - default = SQSParser() - self._parser = ParserComposition(broker._parser, default.parse_message) - self._decoder = ParserComposition(broker._decoder, default.decode_message) self.codec = broker.config.broker_codec or DefaultCodec() @property @@ -193,9 +216,21 @@ async def build_message( if codec is None: codec = DefaultCodec() payload, content_type = await codec.encode(message, serializer=serializer) - body = payload.decode() if isinstance(payload, bytes) else str(payload) + + base64_body = False + if isinstance(payload, bytes): + try: + body = payload.decode() + except UnicodeDecodeError: + # mirror SQSFastProducer: SQS bodies are text-only + body = base64.b64encode(payload).decode() + base64_body = True + else: + body = str(payload) attributes: dict[str, Any] = {} + if base64_body: + attributes[BASE64_BODY_ATTR] = {"DataType": "String", "StringValue": "1"} for key, value in (headers or {}).items(): attributes[key] = {"DataType": "String", "StringValue": str(value)} if content_type: diff --git a/tests/brokers/sqs/basic.py b/tests/brokers/sqs/basic.py index 8ea94d6071e..7ec89594b54 100644 --- a/tests/brokers/sqs/basic.py +++ b/tests/brokers/sqs/basic.py @@ -5,6 +5,15 @@ from faststream.sqs.testing import TestSQSBroker from tests.brokers.base.basic import BaseTestcaseConfig +# Connection settings of the local ElasticMQ emulator (docker-compose `sqs` +# service) — the single source of truth for the whole SQS test suite. +ELASTICMQ_CONNECTION: dict[str, str] = { + "endpoint_url": "http://localhost:9324", + "region_name": "us-east-1", + "aws_access_key_id": "test", + "aws_secret_access_key": "test", +} + class SQSTestcaseConfig(BaseTestcaseConfig): # SQS is poll-based (long-polling), so round-trips are slower than the @@ -17,10 +26,7 @@ def get_broker( **kwargs: Any, ) -> SQSBroker: return SQSBroker( - endpoint_url="http://localhost:9324", - region_name="us-east-1", - aws_access_key_id="test", - aws_secret_access_key="test", + **ELASTICMQ_CONNECTION, apply_types=apply_types, **kwargs, ) diff --git a/tests/brokers/sqs/conftest.py b/tests/brokers/sqs/conftest.py index 54a34684661..974936f424b 100644 --- a/tests/brokers/sqs/conftest.py +++ b/tests/brokers/sqs/conftest.py @@ -4,13 +4,15 @@ from faststream.sqs.broker.router import SQSRouter +from .basic import ELASTICMQ_CONNECTION + @dataclass class Settings: - endpoint_url: str = "http://localhost:9324" - region_name: str = "us-east-1" - aws_access_key_id: str = "test" - aws_secret_access_key: str = "test" + endpoint_url: str = ELASTICMQ_CONNECTION["endpoint_url"] + region_name: str = ELASTICMQ_CONNECTION["region_name"] + aws_access_key_id: str = ELASTICMQ_CONNECTION["aws_access_key_id"] + aws_secret_access_key: str = ELASTICMQ_CONNECTION["aws_secret_access_key"] @pytest.fixture(scope="session") diff --git a/tests/brokers/sqs/test_fastapi.py b/tests/brokers/sqs/test_fastapi.py index 06f443b0295..760faf78f7f 100644 --- a/tests/brokers/sqs/test_fastapi.py +++ b/tests/brokers/sqs/test_fastapi.py @@ -6,7 +6,7 @@ from faststream.sqs.fastapi import SQSRouter as _SQSStreamRouter from tests.brokers.base.fastapi import FastAPILocalTestcase, FastAPITestcase -from .basic import SQSMemoryTestcaseConfig, SQSTestcaseConfig +from .basic import ELASTICMQ_CONNECTION, SQSMemoryTestcaseConfig, SQSTestcaseConfig class StreamRouter(_SQSStreamRouter): @@ -17,10 +17,8 @@ class StreamRouter(_SQSStreamRouter): """ def __init__(self, *args: Any, **kwargs: Any) -> None: - kwargs.setdefault("endpoint_url", "http://localhost:9324") - kwargs.setdefault("region_name", "us-east-1") - kwargs.setdefault("aws_access_key_id", "test") - kwargs.setdefault("aws_secret_access_key", "test") + for key, value in ELASTICMQ_CONNECTION.items(): + kwargs.setdefault(key, value) super().__init__(*args, **kwargs) @@ -35,3 +33,12 @@ class TestRouter(SQSTestcaseConfig, FastAPITestcase): class TestRouterLocal(SQSMemoryTestcaseConfig, FastAPILocalTestcase): router_class = StreamRouter broker_router_class = SQSRouter + + +@pytest.mark.sqs() +def test_subscriber_accepts_batch() -> None: + router = StreamRouter() + + sub = router.subscriber("test-queue", batch=True) + + assert sub._batch diff --git a/tests/brokers/sqs/test_features.py b/tests/brokers/sqs/test_features.py index 9b8a21c038e..e208448d06b 100644 --- a/tests/brokers/sqs/test_features.py +++ b/tests/brokers/sqs/test_features.py @@ -5,11 +5,13 @@ """ import asyncio +from typing import Any from unittest.mock import AsyncMock import pytest from faststream import Context +from faststream._internal.parser import DefaultCodec from faststream.exceptions import SetupError from faststream.response.publish_type import PublishType from faststream.sqs import FifoQueue @@ -20,9 +22,9 @@ TooManyMessageAttributesError, ) from faststream.sqs.message import SQSBatchMessage, SQSMessage -from faststream.sqs.parser import SQSParser +from faststream.sqs.parser import BASE64_BODY_ATTR, SQSParser from faststream.sqs.publisher.producer import SQSFastProducer -from faststream.sqs.response import SQSPublishCommand +from faststream.sqs.response import SQSBatchPublishCommand, SQSPublishCommand from .basic import SQSMemoryTestcaseConfig, SQSTestcaseConfig @@ -137,6 +139,105 @@ async def test_empty_receive_count_defaults_to_zero(self) -> None: msg = await parser.parse_message({"Body": "x"}) assert msg.approximate_receive_count == 0 + async def test_system_attributes_not_shared_between_messages(self) -> None: + parser = SQSParser() + first = await parser.parse_message({"Body": "x"}) + first.system_attributes["MessageGroupId"] = "polluted" + + second = await parser.parse_message({"Body": "y"}) + assert second.group_id is None + assert SQSMessage(raw_message={}, body=b"").system_attributes == {} + + async def test_batch_system_attributes(self) -> None: + parser = SQSParser() + raws = [ + { + "Body": "a", + "Attributes": {"MessageGroupId": "g1", "ApproximateReceiveCount": "2"}, + }, + { + "Body": "b", + "Attributes": {"MessageGroupId": "g2", "ApproximateReceiveCount": "5"}, + }, + ] + msg = await parser.parse_batch(raws) + # scalars follow the batch "first message wins" convention + assert msg.group_id == "g1" + assert msg.approximate_receive_count == 2 + assert [a["MessageGroupId"] for a in msg.batch_system_attributes] == ["g1", "g2"] + + async def test_decode_batch_uses_cached_parses(self) -> None: + parser = SQSParser() + msg = await parser.parse_batch([{"Body": "a"}, {"Body": "b"}]) + assert await parser.decode_batch(msg) == [b"a", b"b"] + + +@pytest.mark.asyncio() +class TestBinaryBody: + async def test_non_utf8_payload_roundtrip(self) -> None: + producer = SQSFastProducer() + payload = b"\x89PNG\r\n\x1a\x00\xff\xfe" + + cmd = SQSPublishCommand(payload, queue="q", _publish_type=PublishType.PUBLISH) + body, attrs = producer._build_entry(payload, None, cmd) + assert BASE64_BODY_ATTR in attrs + + msg = await SQSParser().parse_message( + {"Body": body, "MessageAttributes": attrs}, + ) + assert msg.body == payload + + async def test_utf8_payload_stays_plain_text(self) -> None: + producer = SQSFastProducer() + cmd = SQSPublishCommand(b"hello", queue="q", _publish_type=PublishType.PUBLISH) + + body, attrs = producer._build_entry(b"hello", None, cmd) + + assert body == "hello" + assert BASE64_BODY_ATTR not in attrs + + +class _RecordingBatchCodec(DefaultCodec): + """A user codec implementing the ``BatchCodecProto`` extension.""" + + def __init__(self) -> None: + self.batch_calls = 0 + + async def encode_batch( + self, + msgs: Any, + serializer: Any = None, + ) -> list[tuple[bytes, str | None]]: + self.batch_calls += 1 + return [await self.encode(msg, serializer) for msg in msgs] + + async def decode_batch(self, msg: Any) -> list[Any]: + return list(msg.body) + + +@pytest.mark.asyncio() +class TestBatchCodec: + async def test_publish_batch_prefers_encode_batch(self) -> None: + codec = _RecordingBatchCodec() + + client = AsyncMock() + client.get_queue_url.return_value = {"QueueUrl": "http://q"} + client.send_message_batch.return_value = {"Successful": [], "Failed": []} + + producer = SQSFastProducer() + producer.connect(client, serializer=None, codec=codec) + + cmd = SQSBatchPublishCommand( + "a", + "b", + queue="q", + _publish_type=PublishType.PUBLISH, + ) + await producer.publish_batch(cmd) + + assert codec.batch_calls == 1 + client.send_message_batch.assert_awaited_once() + @pytest.mark.asyncio() class TestMessageAck: @@ -186,10 +287,48 @@ async def handler(msg) -> None: handler.mock.assert_called_once_with(["hello"]) +@pytest.mark.asyncio() +class TestBinaryBodyMemory(SQSMemoryTestcaseConfig): + async def test_publish_binary_in_memory(self, queue: str) -> None: + broker = self.get_broker() + payload = b"\x89PNG\r\n\x1a\x00\xff\xfe" + + @broker.subscriber(queue) + async def handler(msg) -> None: + pass + + async with self.patch_broker(broker) as br: + await br.publish(payload, queue) + handler.mock.assert_called_once_with(payload) + + @pytest.mark.connected() @pytest.mark.sqs() @pytest.mark.asyncio() class TestConnectedFeatures(SQSTestcaseConfig): + async def test_binary_publish_consume(self, queue: str, event: asyncio.Event) -> None: + broker = self.get_broker(apply_types=True) + payload = b"\x89PNG\r\n\x1a\x00\xff\xfe" + received: list[bytes] = [] + + @broker.subscriber(queue) + async def handler(msg: bytes) -> None: + received.append(msg) + event.set() + + async with broker: + await broker.start() + await asyncio.wait( + ( + asyncio.create_task(broker.publish(payload, queue)), + asyncio.create_task(event.wait()), + ), + timeout=self.timeout, + ) + + assert event.is_set() + assert received == [payload] + async def test_batch_consume(self, queue: str, event: asyncio.Event) -> None: broker = self.get_broker(apply_types=True) received: list = [] diff --git a/tests/mypy/sqs.py b/tests/mypy/sqs.py new file mode 100644 index 00000000000..1242f5a3993 --- /dev/null +++ b/tests/mypy/sqs.py @@ -0,0 +1,23 @@ +from typing_extensions import assert_type + +from faststream.sqs import SQSBroker, SQSMessage, TestSQSBroker + + +async def check_multiple_test_brokers() -> None: + async with TestSQSBroker(SQSBroker()) as br1: + assert_type(br1, SQSBroker) + await br1.publish(None, "test") + + async with TestSQSBroker( + SQSBroker(), + SQSBroker(), + ) as (br1, br2): + await br1.publish(None, "test") + await br2.publish(None, "test") + + +async def check_request_response_type() -> None: + broker = SQSBroker() + + broker_response = await broker.request(None, "test") + assert_type(broker_response, SQSMessage) diff --git a/tests/opentelemetry/sqs/test_provider.py b/tests/opentelemetry/sqs/test_provider.py new file mode 100644 index 00000000000..d83d5581494 --- /dev/null +++ b/tests/opentelemetry/sqs/test_provider.py @@ -0,0 +1,76 @@ +from types import SimpleNamespace + +import pytest +from opentelemetry.semconv.trace import SpanAttributes as SpanAttr + +from faststream.opentelemetry.consts import MESSAGING_DESTINATION_PUBLISH_NAME +from faststream.sqs.opentelemetry.provider import ( + BatchSQSTelemetrySettingsProvider, + SQSTelemetrySettingsProvider, + telemetry_attributes_provider_factory, +) + + +@pytest.mark.sqs() +def test_single_consume_attrs() -> None: + body = b"Hello" + queue_url = "http://localhost:9324/000000000000/queue" + message = SimpleNamespace( + body=body, + queue_url=queue_url, + message_id="1", + correlation_id="1", + ) + + attrs = SQSTelemetrySettingsProvider().get_consume_attrs_from_message(message) + + assert attrs[SpanAttr.MESSAGING_MESSAGE_PAYLOAD_SIZE_BYTES] == len(body) + assert attrs[MESSAGING_DESTINATION_PUBLISH_NAME] == queue_url + + +@pytest.mark.sqs() +def test_batch_consume_attrs() -> None: + bodies = [b"Hi ", b"again, ", b"FastStream!"] + queue_url = "http://localhost:9324/000000000000/queue" + message = SimpleNamespace( + body=bodies, + queue_url=queue_url, + message_id="1", + correlation_id="1", + raw_message=[SimpleNamespace() for _ in bodies], + ) + + attrs = BatchSQSTelemetrySettingsProvider().get_consume_attrs_from_message(message) + + assert attrs[SpanAttr.MESSAGING_MESSAGE_PAYLOAD_SIZE_BYTES] == len( + bytearray().join(bodies), + ) + assert attrs[SpanAttr.MESSAGING_BATCH_MESSAGE_COUNT] == len(bodies) + assert attrs[MESSAGING_DESTINATION_PUBLISH_NAME] == queue_url + + +@pytest.mark.sqs() +@pytest.mark.parametrize( + ("msg", "expected_provider"), + ( + pytest.param( + [{"Body": "1"}, {"Body": "2"}], + BatchSQSTelemetrySettingsProvider(), + id="batch message", + ), + pytest.param( + {"Body": "1"}, + SQSTelemetrySettingsProvider(), + id="single message", + ), + pytest.param( + None, + SQSTelemetrySettingsProvider(), + id="None message", + ), + ), +) +def test_telemetry_provider_factory(msg, expected_provider) -> None: + provider = telemetry_attributes_provider_factory(msg) + + assert isinstance(provider, type(expected_provider)) diff --git a/tests/prometheus/sqs/basic.py b/tests/prometheus/sqs/basic.py index 4cdd06f008c..f00db639ae0 100644 --- a/tests/prometheus/sqs/basic.py +++ b/tests/prometheus/sqs/basic.py @@ -1,7 +1,10 @@ from typing import Any from faststream.sqs.prometheus import SQSPrometheusMiddleware -from faststream.sqs.prometheus.provider import SQSMetricsSettingsProvider +from faststream.sqs.prometheus.provider import ( + BatchSQSMetricsSettingsProvider, + SQSMetricsSettingsProvider, +) from tests.brokers.sqs.basic import SQSTestcaseConfig @@ -13,3 +16,8 @@ def get_middleware(self, **kwargs: Any) -> SQSPrometheusMiddleware: def get_settings_provider(self) -> SQSMetricsSettingsProvider: return SQSMetricsSettingsProvider() + + +class BatchSQSPrometheusSettings(SQSPrometheusSettings): + def get_settings_provider(self) -> BatchSQSMetricsSettingsProvider: # type: ignore[override] + return BatchSQSMetricsSettingsProvider() diff --git a/tests/prometheus/sqs/test_provider.py b/tests/prometheus/sqs/test_provider.py index 3ff08e4737e..ed5d7c0e15d 100644 --- a/tests/prometheus/sqs/test_provider.py +++ b/tests/prometheus/sqs/test_provider.py @@ -2,14 +2,17 @@ import pytest +from faststream.sqs.prometheus.provider import ( + BatchSQSMetricsSettingsProvider, + SQSMetricsSettingsProvider, + settings_provider_factory, +) from tests.prometheus.basic import LocalMetricsSettingsProviderTestcase -from .basic import SQSPrometheusSettings +from .basic import BatchSQSPrometheusSettings, SQSPrometheusSettings -@pytest.mark.sqs() -class TestSQSMetricsSettingsProvider( - SQSPrometheusSettings, +class LocalBaseSQSMetricsSettingsProviderTestcase( LocalMetricsSettingsProviderTestcase, ): def test_get_publish_destination_name_from_cmd(self, queue: str) -> None: @@ -20,10 +23,16 @@ def test_get_publish_destination_name_from_cmd(self, queue: str) -> None: assert destination_name == queue + +@pytest.mark.sqs() +class TestSQSMetricsSettingsProvider( + SQSPrometheusSettings, + LocalBaseSQSMetricsSettingsProviderTestcase, +): def test_get_consume_attrs_from_message(self, queue: str) -> None: body = b"Hello" # SQS reports the consume destination by queue URL. - queue_url = f"http://localhost:4566/000000000000/{queue}" + queue_url = f"http://localhost:9324/000000000000/{queue}" expected_attrs = { "destination_name": queue_url, "message_size": len(body), @@ -36,3 +45,55 @@ def test_get_consume_attrs_from_message(self, queue: str) -> None: attrs = provider.get_consume_attrs_from_message(message) assert attrs == expected_attrs + + +@pytest.mark.sqs() +class TestBatchSQSMetricsSettingsProvider( + BatchSQSPrometheusSettings, + LocalBaseSQSMetricsSettingsProviderTestcase, +): + def test_get_consume_attrs_from_message(self, queue: str) -> None: + bodies = [b"Hi ", b"again, ", b"FastStream!"] + queue_url = f"http://localhost:9324/000000000000/{queue}" + message = SimpleNamespace( + body=bodies, + queue_url=queue_url, + raw_message=[SimpleNamespace() for _ in bodies], + ) + expected_attrs = { + "destination_name": queue_url, + "message_size": len(bytearray().join(bodies)), + "messages_count": len(message.raw_message), + } + + provider = self.get_settings_provider() + attrs = provider.get_consume_attrs_from_message(message) + + assert attrs == expected_attrs + + +@pytest.mark.sqs() +@pytest.mark.parametrize( + ("msg", "expected_provider"), + ( + pytest.param( + [{"Body": "1"}, {"Body": "2"}], + BatchSQSMetricsSettingsProvider(), + id="batch message", + ), + pytest.param( + {"Body": "1"}, + SQSMetricsSettingsProvider(), + id="single message", + ), + pytest.param( + None, + SQSMetricsSettingsProvider(), + id="None message", + ), + ), +) +def test_settings_provider_factory(msg, expected_provider) -> None: + provider = settings_provider_factory(msg) + + assert isinstance(provider, type(expected_provider)) From 30fe423061f5fe6776accdaa350d1aa4e06ac39a Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Wed, 10 Jun 2026 23:30:25 +0300 Subject: [PATCH 10/19] refactor(testing): make TestBroker __init__ overloads type-only Move every concrete TestBroker's EnterType-binding overloads under `if TYPE_CHECKING:` so no runtime `__init__` frames are added (mypy cannot infer a subclass type argument from base-class overloads, so per-subclass copies stay, but as pure type stubs). This lets the AST-based `connect_only` detection go back to the deterministic `extract_stack()[-3]` lookup, fixing misdetection when users open `with TestBroker(...)` inside their own `__init__`. The multi-broker `__aenter__` now returns a tuple, matching the declared types, and the try-it-out factory no longer needs its `cast` workaround. Co-Authored-By: Claude Fable 5 --- faststream/_internal/testing/ast.py | 20 +++---- faststream/_internal/testing/broker.py | 9 ++- .../asgi/factories/asyncapi/try_it_out.py | 13 +---- faststream/confluent/testing.py | 58 +++++++++---------- faststream/kafka/testing.py | 58 +++++++++---------- faststream/mqtt/testing.py | 58 +++++++++---------- faststream/nats/testing.py | 58 +++++++++---------- faststream/rabbit/testing.py | 58 +++++++++---------- faststream/redis/testing.py | 58 +++++++++---------- tests/utils/test_ast.py | 32 +++++----- 10 files changed, 208 insertions(+), 214 deletions(-) diff --git a/faststream/_internal/testing/ast.py b/faststream/_internal/testing/ast.py index baee68281bc..baf0ad15657 100644 --- a/faststream/_internal/testing/ast.py +++ b/faststream/_internal/testing/ast.py @@ -7,18 +7,14 @@ def is_contains_context_name(scip_name: str, name: str) -> bool: - stack = traceback.extract_stack() - # Walk out past this frame and the ``TestBroker.__init__`` chain (the - # abstract base plus the concrete subclass override) to the user frame - # that opened the ``with`` block, regardless of how many ``__init__`` - # frames sit in between. - idx = len(stack) - 2 - while idx > 0 and stack[idx].name == "__init__": - idx -= 1 - frame = stack[idx] - - tree = _read_source_ast(frame.filename) - node = cast("ast.With | ast.AsyncWith", _find_ast_node(tree, frame.lineno)) + # ``[-3]``: the user frame that opened the ``with`` block, right above + # ``TestBroker.__init__``/``TestApp.__init__`` and this helper. Concrete + # ``TestBroker`` subclasses must not override ``__init__`` at runtime + # (their typing overloads are ``TYPE_CHECKING``-only), or this offset + # would land on the wrong frame. + stack = traceback.extract_stack()[-3] + tree = _read_source_ast(stack.filename) + node = cast("ast.With | ast.AsyncWith", _find_ast_node(tree, stack.lineno)) context_calls = _get_withitem_calls(node) try: diff --git a/faststream/_internal/testing/broker.py b/faststream/_internal/testing/broker.py index dcee3925b44..5d45c08f7fb 100644 --- a/faststream/_internal/testing/broker.py +++ b/faststream/_internal/testing/broker.py @@ -27,7 +27,11 @@ Broker = TypeVar("Broker", bound=BrokerUsecase[Any, Any]) # ``__aenter__`` return type. Each concrete ``TestBroker`` subclass binds it to a -# single broker or a ``tuple`` of brokers via its overloaded ``__init__``. +# single broker or a ``tuple`` of brokers via ``TYPE_CHECKING``-only ``__init__`` +# overloads (mypy can't infer a subclass type argument from overloads declared on +# this base, so each subclass carries its own copy; keeping them type-only leaves +# the runtime stack free of extra ``__init__`` frames, which the AST-based +# ``connect_only`` detection relies on). EnterType = TypeVar313("EnterType", default=Any) @@ -86,7 +90,8 @@ async def __aenter__(self) -> EnterType: brokers = await self._ctx.__aenter__() if len(brokers) == 1: return cast("EnterType", brokers[0]) - return cast("EnterType", brokers) + # a tuple, matching the multi-broker ``__init__`` overload declarations + return cast("EnterType", tuple(brokers)) async def __aexit__( self, diff --git a/faststream/asgi/factories/asyncapi/try_it_out.py b/faststream/asgi/factories/asyncapi/try_it_out.py index 471f5fd5736..4eae23c51de 100644 --- a/faststream/asgi/factories/asyncapi/try_it_out.py +++ b/faststream/asgi/factories/asyncapi/try_it_out.py @@ -1,7 +1,7 @@ -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Iterable, Sequence from contextlib import suppress from functools import lru_cache -from typing import TYPE_CHECKING, Any, TypedDict, Union, cast +from typing import TYPE_CHECKING, Any, TypedDict, Union from faststream.asgi.annotations import Request from faststream.asgi.handlers import PostHandler, post @@ -112,14 +112,7 @@ async def process(self, body: TryItOutForm) -> AsgiResponse: if cls is test_broker_cls and b is not broker ), ) - # ``test_broker_cls`` is typed as the abstract ``TestBroker`` base, whose - # overloaded ``__init__`` would make mypy try to instantiate the abstract - # class. At runtime it is always a concrete subclass; the enter result is - # unused here, so call it through a plain factory type. - test_broker_factory = cast( - "Callable[..., TestBroker[Any, Any]]", test_broker_cls - ) - async with test_broker_factory(*same_type_brokers): + async with test_broker_cls(*same_type_brokers): data = await broker.request(payload, destination, timeout=30) decoded = None with suppress(Exception): diff --git a/faststream/confluent/testing.py b/faststream/confluent/testing.py index d637f43ce32..45b9f231640 100644 --- a/faststream/confluent/testing.py +++ b/faststream/confluent/testing.py @@ -39,35 +39,35 @@ class TestKafkaBroker(TestBroker[KafkaBroker, EnterType]): """A class to test Kafka brokers.""" - @overload - def __init__( - self: "TestKafkaBroker[KafkaBroker]", - broker: KafkaBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestKafkaBroker[tuple[KafkaBroker, ...]]", - *brokers: KafkaBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: KafkaBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: - super().__init__( - *brokers, - with_real=with_real, - connect_only=connect_only, - ) + # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker + # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the + # AST-based ``connect_only`` detection in ``TestBroker`` relies on. + if TYPE_CHECKING: + + @overload + def __init__( + self: "TestKafkaBroker[KafkaBroker]", + broker: KafkaBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestKafkaBroker[tuple[KafkaBroker, ...]]", + *brokers: KafkaBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: KafkaBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... @contextmanager def _patch_producer(self, broker: KafkaBroker) -> Iterator[None]: diff --git a/faststream/kafka/testing.py b/faststream/kafka/testing.py index c8c1ef7dc82..eb24fc48db6 100755 --- a/faststream/kafka/testing.py +++ b/faststream/kafka/testing.py @@ -41,35 +41,35 @@ class TestKafkaBroker(TestBroker[KafkaBroker, EnterType]): """A class to test Kafka brokers.""" - @overload - def __init__( - self: "TestKafkaBroker[KafkaBroker]", - broker: KafkaBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestKafkaBroker[tuple[KafkaBroker, ...]]", - *brokers: KafkaBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: KafkaBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: - super().__init__( - *brokers, - with_real=with_real, - connect_only=connect_only, - ) + # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker + # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the + # AST-based ``connect_only`` detection in ``TestBroker`` relies on. + if TYPE_CHECKING: + + @overload + def __init__( + self: "TestKafkaBroker[KafkaBroker]", + broker: KafkaBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestKafkaBroker[tuple[KafkaBroker, ...]]", + *brokers: KafkaBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: KafkaBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... @contextmanager def _patch_producer(self, broker: KafkaBroker) -> Iterator[None]: diff --git a/faststream/mqtt/testing.py b/faststream/mqtt/testing.py index 518bf892027..e7be3af2e5a 100644 --- a/faststream/mqtt/testing.py +++ b/faststream/mqtt/testing.py @@ -85,35 +85,35 @@ class TestMQTTBroker(TestBroker[MQTTBroker, EnterType]): handler.mock.assert_called_once_with("hello") """ - @overload - def __init__( - self: "TestMQTTBroker[MQTTBroker]", - broker: MQTTBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestMQTTBroker[tuple[MQTTBroker, ...]]", - *brokers: MQTTBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: MQTTBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: - super().__init__( - *brokers, - with_real=with_real, - connect_only=connect_only, - ) + # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker + # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the + # AST-based ``connect_only`` detection in ``TestBroker`` relies on. + if TYPE_CHECKING: + + @overload + def __init__( + self: "TestMQTTBroker[MQTTBroker]", + broker: MQTTBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestMQTTBroker[tuple[MQTTBroker, ...]]", + *brokers: MQTTBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: MQTTBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... def create_publisher_fake_subscriber( self, diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index f8d8b1c5f2b..37b6a45a40c 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -52,35 +52,35 @@ def change_producer( class TestNatsBroker(TestBroker[NatsBroker, EnterType]): """A class to test NATS brokers.""" - @overload - def __init__( - self: "TestNatsBroker[NatsBroker]", - broker: NatsBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestNatsBroker[tuple[NatsBroker, ...]]", - *brokers: NatsBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: NatsBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: - super().__init__( - *brokers, - with_real=with_real, - connect_only=connect_only, - ) + # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker + # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the + # AST-based ``connect_only`` detection in ``TestBroker`` relies on. + if TYPE_CHECKING: + + @overload + def __init__( + self: "TestNatsBroker[NatsBroker]", + broker: NatsBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestNatsBroker[tuple[NatsBroker, ...]]", + *brokers: NatsBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: NatsBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... def create_publisher_fake_subscriber( self, diff --git a/faststream/rabbit/testing.py b/faststream/rabbit/testing.py index c4da14febf4..d43d4cd7c79 100644 --- a/faststream/rabbit/testing.py +++ b/faststream/rabbit/testing.py @@ -46,35 +46,35 @@ class TestRabbitBroker(TestBroker[RabbitBroker, EnterType]): """A class to test RabbitMQ brokers.""" - @overload - def __init__( - self: "TestRabbitBroker[RabbitBroker]", - broker: RabbitBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestRabbitBroker[tuple[RabbitBroker, ...]]", - *brokers: RabbitBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: RabbitBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: - super().__init__( - *brokers, - with_real=with_real, - connect_only=connect_only, - ) + # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker + # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the + # AST-based ``connect_only`` detection in ``TestBroker`` relies on. + if TYPE_CHECKING: + + @overload + def __init__( + self: "TestRabbitBroker[RabbitBroker]", + broker: RabbitBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestRabbitBroker[tuple[RabbitBroker, ...]]", + *brokers: RabbitBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: RabbitBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... @contextmanager def _patch_broker(self, broker: "RabbitBroker") -> Generator[None, None, None]: diff --git a/faststream/redis/testing.py b/faststream/redis/testing.py index 4c40e503b23..9f62dd9f4c2 100644 --- a/faststream/redis/testing.py +++ b/faststream/redis/testing.py @@ -59,35 +59,35 @@ class TestRedisBroker(TestBroker[RedisBroker, EnterType]): """A class to test Redis brokers.""" - @overload - def __init__( - self: "TestRedisBroker[RedisBroker]", - broker: RedisBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestRedisBroker[tuple[RedisBroker, ...]]", - *brokers: RedisBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: RedisBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: - super().__init__( - *brokers, - with_real=with_real, - connect_only=connect_only, - ) + # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker + # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the + # AST-based ``connect_only`` detection in ``TestBroker`` relies on. + if TYPE_CHECKING: + + @overload + def __init__( + self: "TestRedisBroker[RedisBroker]", + broker: RedisBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestRedisBroker[tuple[RedisBroker, ...]]", + *brokers: RedisBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: RedisBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... @asynccontextmanager async def _create_ctx(self) -> AsyncGenerator[list[RedisBroker], None]: diff --git a/tests/utils/test_ast.py b/tests/utils/test_ast.py index 181197724b7..d72912a661f 100644 --- a/tests/utils/test_ast.py +++ b/tests/utils/test_ast.py @@ -30,14 +30,22 @@ def __init__(self) -> None: pass -class SubA(A): - """Mirrors a concrete ``TestBroker`` subclass that delegates to ``super``. +class UserWrapper: + """Mirrors a user helper that opens the ``with`` block inside ``__init__``. - The extra ``__init__`` frame must not break context-name detection. + The user's ``__init__`` frame is a regular caller frame and must not be + skipped by context-name detection. """ def __init__(self) -> None: - super().__init__() + with A() as a, B(): + self.contains = a.contains + + +class UserWrapperInvalid: + def __init__(self) -> None: + with B(), A() as a: + self.contains = a.contains def test_base() -> None: @@ -86,20 +94,12 @@ def test_nested_invalid() -> None: assert not a.contains -def test_subclass_init_chain() -> None: - with SubA() as a, B(): - assert a.contains +def test_user_init_frame() -> None: + assert UserWrapper().contains -@pytest.mark.asyncio() -async def test_subclass_init_chain_async() -> None: - async with SubA() as a, B(): - assert a.contains - - -def test_subclass_init_chain_invalid() -> None: - with B(), SubA() as a: - assert not a.contains +def test_user_init_frame_invalid() -> None: + assert not UserWrapperInvalid().contains def test_not_broken() -> None: From 87fec943fc07751c909ace91b8931bc0b33c4fe4 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Wed, 10 Jun 2026 23:30:25 +0300 Subject: [PATCH 11/19] build(deps): pin types-aiobotocore to the aiobotocore 2.x line The runtime dependency is `aiobotocore>=2.13,<3`, but the lint group pulled 3.x stubs, so mypy validated faststream/sqs against a different major API than the one installed. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 2 +- uv.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9a49267fb34..cfbcf5685e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,7 +127,7 @@ lint = [ "types-Pygments", "types-docutils", "types-aiofiles", - "types-aiobotocore[sqs]>=3.7.0", + "types-aiobotocore[sqs]>=2.13,<3", ] test-core = [ diff --git a/uv.lock b/uv.lock index 93de5071d30..973fea479a8 100644 --- a/uv.lock +++ b/uv.lock @@ -1291,7 +1291,7 @@ dev = [ { name = "ruff", specifier = "==0.14.14" }, { name = "semgrep", specifier = "==1.150.0" }, { name = "setuptools", specifier = ">=80.9.0" }, - { name = "types-aiobotocore", extras = ["sqs"], specifier = ">=3.7.0" }, + { name = "types-aiobotocore", extras = ["sqs"], specifier = ">=2.13,<3" }, { name = "types-aiofiles" }, { name = "types-deprecated" }, { name = "types-docutils" }, @@ -1325,7 +1325,7 @@ lint = [ { name = "mypy", specifier = "==1.19.1" }, { name = "ruff", specifier = "==0.14.14" }, { name = "semgrep", specifier = "==1.150.0" }, - { name = "types-aiobotocore", extras = ["sqs"], specifier = ">=3.7.0" }, + { name = "types-aiobotocore", extras = ["sqs"], specifier = ">=2.13,<3" }, { name = "types-aiofiles" }, { name = "types-deprecated" }, { name = "types-docutils" }, @@ -3952,15 +3952,15 @@ wheels = [ [[package]] name = "types-aiobotocore" -version = "3.7.0" +version = "2.26.0.post2" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/27/c60789f312a3630cbc82181e4d6e809bd8801b471de99f14ceb11f4c5c26/types_aiobotocore-2.26.0.post2.tar.gz", hash = "sha256:68ebe5e9de3201442e56359af182493e2e642e855a9133a5918352cbf5ac4e2d", size = 86472, upload-time = "2025-12-02T16:52:55.101Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/3d/47/e080e365376619d4062da8989747ecf7c8404bd94b2de10904239a3104f0/types_aiobotocore-2.26.0.post2-py3-none-any.whl", hash = "sha256:0e19caffd6ce6b1c3e7ba5b085d1d03357672e1aa65e5bcdfd9efb026a1041f7", size = 54207, upload-time = "2025-12-02T16:52:48.246Z" }, ] [package.optional-dependencies] @@ -3970,14 +3970,14 @@ sqs = [ [[package]] name = "types-aiobotocore-sqs" -version = "3.7.0" +version = "2.26.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" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/59/b54d6985f6ca7f7b5de793358f2154346f337ccf540c7582a2f70624beda/types_aiobotocore_sqs-2.26.0.tar.gz", hash = "sha256:5b3884b272f01ad9a437b6e08c747d5023f90f9354694fa6aa63d70d8d84effb", size = 23418, upload-time = "2025-11-29T01:50:31.604Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/f8/b4/117eb9d1d13c668e5a4308daa8ae19bd74cbab6eabaa6f556752313be767/types_aiobotocore_sqs-2.26.0-py3-none-any.whl", hash = "sha256:654319359aac816f6068105e89ad685952d2999fa97af194ad2ee290dafdf57b", size = 34135, upload-time = "2025-11-29T01:50:29.598Z" }, ] [[package]] From 5488e524e5bbf580e798fb05d849fd4a51091ff7 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sat, 4 Jul 2026 21:14:23 +0300 Subject: [PATCH 12/19] feat(sqs): add queue Policy attribute and SQSProducer annotation Expose the SQS queue `Policy` attribute on the SQSQueue schema and add the `SQSProducer` context annotation for parity with the other brokers. --- faststream/sqs/annotations.py | 3 +++ faststream/sqs/schemas/queue.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/faststream/sqs/annotations.py b/faststream/sqs/annotations.py index f1f8a0244ea..03ecbfcc6a7 100644 --- a/faststream/sqs/annotations.py +++ b/faststream/sqs/annotations.py @@ -5,6 +5,7 @@ from faststream.params import NoCast from faststream.sqs.broker.broker import SQSBroker as SB # noqa: N814 from faststream.sqs.message import SQSMessage as SM # noqa: N814 +from faststream.sqs.publisher.producer import SQSFastProducer __all__ = ( "ContextRepo", @@ -12,7 +13,9 @@ "NoCast", "SQSBroker", "SQSMessage", + "SQSProducer", ) SQSMessage = Annotated[SM, Context("message")] SQSBroker = Annotated[SB, Context("broker")] +SQSProducer = Annotated[SQSFastProducer, Context("broker._producer")] diff --git a/faststream/sqs/schemas/queue.py b/faststream/sqs/schemas/queue.py index 778732acce7..3306461af2c 100644 --- a/faststream/sqs/schemas/queue.py +++ b/faststream/sqs/schemas/queue.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Any, Literal from pydantic import BaseModel, Field @@ -58,6 +58,8 @@ class SQSQueue(BaseModel): visibility_timeout: int | None = Field( default=None, alias="VisibilityTimeout", ge=0, le=43200 ) + # Full IAM access-policy document for the queue (rendered as JSON). + policy: dict[str, Any] | None = Field(default=None, alias="Policy") redrive_policy: RedrivePolicy | None = Field(default=None, alias="RedrivePolicy") redrive_allow_policy: RedriveAllowPolicy | None = Field( default=None, alias="RedriveAllowPolicy" From 785914fe482fbd4d130a91e3073f780c74ad64d5 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sat, 4 Jul 2026 21:14:44 +0300 Subject: [PATCH 13/19] feat(sqs): support batch publishing via publisher(batch=True) Split the publisher into SQSDefaultPublisher and SQSBatchPublisher, selected by the `batch` flag with typed overloads on the registrator and FastAPI router. A batch publisher sends every publish(*messages) call as a single SendMessageBatch request (chunked to the 10-message / 256 KiB limits). --- faststream/sqs/broker/registrator.py | 66 +++++++++++++++++++-- faststream/sqs/broker/router.py | 2 + faststream/sqs/fastapi/fastapi.py | 64 +++++++++++++++++++-- faststream/sqs/publisher/factory.py | 9 ++- faststream/sqs/publisher/usecase.py | 85 +++++++++++++++++++++++----- faststream/sqs/testing.py | 79 +++++++++++++++++++------- 6 files changed, 258 insertions(+), 47 deletions(-) diff --git a/faststream/sqs/broker/registrator.py b/faststream/sqs/broker/registrator.py index fa30131fb4a..033334a1ef3 100644 --- a/faststream/sqs/broker/registrator.py +++ b/faststream/sqs/broker/registrator.py @@ -1,5 +1,5 @@ from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING, Any, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast, overload from typing_extensions import override @@ -17,7 +17,7 @@ from faststream._internal.parser import CodecProto from faststream._internal.types import BrokerMiddleware, CustomCallable - from faststream.sqs.publisher.usecase import SQSPublisher + from faststream.sqs.publisher.usecase import SQSBatchPublisher, SQSDefaultPublisher from faststream.sqs.schemas import SQSQueue from faststream.sqs.subscriber.usecase import SQSSubscriber @@ -95,11 +95,66 @@ def subscriber( # type: ignore[override] dependencies_=dependencies, ) + @overload # type: ignore[override] + def publisher( + self, + queue: Union[str, "SQSQueue"], + *, + batch: Literal[True], + headers: dict[str, str] | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + schema: Any | None = None, + include_in_schema: bool = True, + ) -> "SQSBatchPublisher": ... + + @overload + def publisher( + self, + queue: Union[str, "SQSQueue"], + *, + batch: Literal[False] = False, + headers: dict[str, str] | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + schema: Any | None = None, + include_in_schema: bool = True, + ) -> "SQSDefaultPublisher": ... + + @overload + def publisher( + self, + queue: Union[str, "SQSQueue"], + *, + batch: bool = False, + headers: dict[str, str] | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + schema: Any | None = None, + include_in_schema: bool = True, + ) -> "SQSDefaultPublisher | SQSBatchPublisher": ... + @override - def publisher( # type: ignore[override] + def publisher( self, queue: Union[str, "SQSQueue"], *, + batch: bool = False, headers: dict[str, str] | None = None, group_id: str | None = None, deduplication_id: str | None = None, @@ -110,11 +165,13 @@ def publisher( # type: ignore[override] description: str | None = None, schema: Any | None = None, include_in_schema: bool = True, - ) -> "SQSPublisher": + ) -> "SQSDefaultPublisher | SQSBatchPublisher": """Create a persistent publisher object for the given SQS queue. Args: queue: Queue name or an ``SQSQueue``/``FifoQueue`` declaration. + batch: Send every ``publish(*messages)`` call as one + ``SendMessageBatch`` request instead of a single message. headers: Default headers to include in every published message. group_id: Default ``MessageGroupId`` for FIFO queues. deduplication_id: Default ``MessageDeduplicationId`` for FIFO queues. @@ -127,6 +184,7 @@ def publisher( # type: ignore[override] """ publisher = create_publisher( queue=queue, + batch=batch, headers=headers, group_id=group_id, deduplication_id=deduplication_id, diff --git a/faststream/sqs/broker/router.py b/faststream/sqs/broker/router.py index ad06019b57d..d288b6cdc08 100644 --- a/faststream/sqs/broker/router.py +++ b/faststream/sqs/broker/router.py @@ -27,6 +27,7 @@ def __init__( self, queue: Union[str, "SQSQueue"], *, + batch: bool = False, headers: dict[str, str] | None = None, group_id: str | None = None, deduplication_id: str | None = None, @@ -39,6 +40,7 @@ def __init__( ) -> None: super().__init__( queue, + batch=batch, headers=headers, group_id=group_id, deduplication_id=deduplication_id, diff --git a/faststream/sqs/fastapi/fastapi.py b/faststream/sqs/fastapi/fastapi.py index 97d482b54f8..cf766570cff 100644 --- a/faststream/sqs/fastapi/fastapi.py +++ b/faststream/sqs/fastapi/fastapi.py @@ -1,6 +1,6 @@ import logging from collections.abc import Callable, Iterable, Sequence -from typing import TYPE_CHECKING, Any, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast, overload from fastapi.datastructures import Default from fastapi.routing import APIRoute @@ -31,7 +31,7 @@ from faststream.security import BaseSecurity from faststream.specification.base import SpecificationFactory from faststream.specification.schema.extra import Tag, TagDict - from faststream.sqs.publisher.usecase import SQSPublisher + from faststream.sqs.publisher.usecase import SQSBatchPublisher, SQSDefaultPublisher from faststream.sqs.schemas import SQSQueue from faststream.sqs.subscriber.usecase import SQSSubscriber @@ -208,11 +208,66 @@ def subscriber( # type: ignore[override] ), ) + @overload # type: ignore[override] + def publisher( + self, + queue: Union[str, "SQSQueue"], + *, + batch: Literal[True], + headers: dict[str, str] | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + schema: Any | None = None, + include_in_schema: bool = True, + ) -> "SQSBatchPublisher": ... + + @overload + def publisher( + self, + queue: Union[str, "SQSQueue"], + *, + batch: Literal[False] = False, + headers: dict[str, str] | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + schema: Any | None = None, + include_in_schema: bool = True, + ) -> "SQSDefaultPublisher": ... + + @overload + def publisher( + self, + queue: Union[str, "SQSQueue"], + *, + batch: bool = False, + headers: dict[str, str] | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, + delay_seconds: int = 0, + persistent: bool = True, + # AsyncAPI information + title: str | None = None, + description: str | None = None, + schema: Any | None = None, + include_in_schema: bool = True, + ) -> "SQSDefaultPublisher | SQSBatchPublisher": ... + @override - def publisher( # type: ignore[override] + def publisher( self, queue: Union[str, "SQSQueue"], *, + batch: bool = False, headers: dict[str, str] | None = None, group_id: str | None = None, deduplication_id: str | None = None, @@ -223,9 +278,10 @@ def publisher( # type: ignore[override] description: str | None = None, schema: Any | None = None, include_in_schema: bool = True, - ) -> "SQSPublisher": + ) -> "SQSDefaultPublisher | SQSBatchPublisher": return self.broker.publisher( queue, + batch=batch, headers=headers, group_id=group_id, deduplication_id=deduplication_id, diff --git a/faststream/sqs/publisher/factory.py b/faststream/sqs/publisher/factory.py index b63cc1aa461..21dc143b737 100644 --- a/faststream/sqs/publisher/factory.py +++ b/faststream/sqs/publisher/factory.py @@ -4,7 +4,7 @@ from .config import SQSPublisherConfig, SQSPublisherSpecificationConfig from .specification import SQSPublisherSpecification -from .usecase import SQSPublisher +from .usecase import SQSBatchPublisher, SQSDefaultPublisher if TYPE_CHECKING: from faststream.sqs.configs import SQSBrokerConfig @@ -13,6 +13,7 @@ def create_publisher( *, queue: Union[str, "SQSQueue"], + batch: bool, headers: dict[str, str] | None, group_id: str | None, deduplication_id: str | None, @@ -23,7 +24,7 @@ def create_publisher( title_: str | None, description_: str | None, include_in_schema: bool, -) -> SQSPublisher: +) -> SQSDefaultPublisher | SQSBatchPublisher: queue_name = queue.queue_name if isinstance(queue, SQSQueue) else queue publisher_config = SQSPublisherConfig( @@ -46,4 +47,6 @@ def create_publisher( ), ) - return SQSPublisher(publisher_config, specification) + if batch: + return SQSBatchPublisher(publisher_config, specification) + return SQSDefaultPublisher(publisher_config, specification) diff --git a/faststream/sqs/publisher/usecase.py b/faststream/sqs/publisher/usecase.py index c3770bc5a0a..751deffed29 100644 --- a/faststream/sqs/publisher/usecase.py +++ b/faststream/sqs/publisher/usecase.py @@ -1,12 +1,12 @@ from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any, Union, cast from typing_extensions import override from faststream._internal.endpoint.publisher import PublisherUsecase from faststream.message import gen_cor_id from faststream.response.publish_type import PublishType -from faststream.sqs.response import SQSPublishCommand +from faststream.sqs.response import SQSBatchPublishCommand, SQSPublishCommand if TYPE_CHECKING: from faststream._internal.basic_types import SendableMessage @@ -14,12 +14,13 @@ from faststream._internal.types import PublisherMiddleware from faststream.response.response import PublishCommand from faststream.sqs.configs import SQSBrokerConfig + from faststream.sqs.message import SQSMessage from .config import SQSPublisherConfig class SQSPublisher(PublisherUsecase): - """Publisher for an SQS queue.""" + """Base SQS publisher (see ``SQSDefaultPublisher`` / ``SQSBatchPublisher``).""" _outer_config: "SQSBrokerConfig" @@ -40,6 +41,34 @@ def __init__( def queue(self) -> str: return f"{self._outer_config.prefix}{self._queue}" + @override + async def request( + self, + message: "SendableMessage", + queue: str = "", + *, + correlation_id: str | None = None, + headers: dict[str, str] | None = None, + timeout: float | None = 30.0, + ) -> "SQSMessage": + cmd = SQSPublishCommand( + message, + queue=queue or self.queue, + headers=self.headers | (headers or {}), + correlation_id=correlation_id or gen_cor_id(), + group_id=self.group_id, + timeout=timeout, + _publish_type=PublishType.REQUEST, + ) + return cast( + "SQSMessage", + await self._basic_request(cmd, producer=self._outer_config.producer), + ) + + +class SQSDefaultPublisher(SQSPublisher): + """Publisher sending one message per ``SendMessage`` request.""" + @override async def publish( self, @@ -89,23 +118,51 @@ async def _publish( _extra_middlewares=_extra_middlewares, ) + +class SQSBatchPublisher(SQSPublisher): + """Publisher sending several messages as one ``SendMessageBatch`` request.""" + @override - async def request( + async def publish( self, - message: "SendableMessage", + *messages: "SendableMessage", queue: str = "", - *, - correlation_id: str | None = None, headers: dict[str, str] | None = None, - timeout: float | None = 30.0, + correlation_id: str | None = None, + group_id: str | None = None, + deduplication_id: str | None = None, ) -> Any: - cmd = SQSPublishCommand( - message, + cmd = SQSBatchPublishCommand( + *messages, queue=queue or self.queue, headers=self.headers | (headers or {}), correlation_id=correlation_id or gen_cor_id(), - group_id=self.group_id, - timeout=timeout, - _publish_type=PublishType.REQUEST, + group_id=group_id or self.group_id, + deduplication_id=deduplication_id or self.deduplication_id, + _publish_type=PublishType.PUBLISH, + ) + return await self._basic_publish_batch( + cmd, + producer=self._outer_config.producer, + _extra_middlewares=(), + ) + + @override + async def _publish( + self, + cmd: Union["PublishCommand", "SQSPublishCommand"], + *, + _extra_middlewares: Iterable["PublisherMiddleware"], + ) -> None: + """Called in the subscriber (reply-to / decorator) flow only.""" + batch_cmd = SQSBatchPublishCommand.from_cmd(cmd, batch=True) + batch_cmd.destination = batch_cmd.destination or self.queue + batch_cmd.add_headers(self.headers, override=False) + batch_cmd.group_id = batch_cmd.group_id or self.group_id + batch_cmd.deduplication_id = batch_cmd.deduplication_id or self.deduplication_id + + await self._basic_publish_batch( + batch_cmd, + producer=self._outer_config.producer, + _extra_middlewares=_extra_middlewares, ) - return await self._basic_request(cmd, producer=self._outer_config.producer) diff --git a/faststream/sqs/testing.py b/faststream/sqs/testing.py index acd0a8e595a..5b1a12a32c7 100644 --- a/faststream/sqs/testing.py +++ b/faststream/sqs/testing.py @@ -8,14 +8,13 @@ import anyio from typing_extensions import override -from faststream._internal.parser import DefaultCodec +from faststream._internal.parser import BatchCodecProto, DefaultCodec from faststream._internal.testing.broker import EnterType, TestBroker, change_producer from faststream.exceptions import SubscriberNotFound from faststream.message import gen_cor_id from faststream.sqs.broker.broker import SQSBroker from faststream.sqs.parser import BASE64_BODY_ATTR from faststream.sqs.publisher.producer import SQSFastProducer -from faststream.sqs.response import SQSPublishCommand if TYPE_CHECKING: from fast_depends.library.serializer import SerializerProto @@ -23,6 +22,7 @@ from faststream._internal.basic_types import SendableMessage from faststream._internal.parser import CodecProto from faststream.sqs.publisher.usecase import SQSPublisher + from faststream.sqs.response import SQSPublishCommand from faststream.sqs.subscriber.usecase import SQSSubscriber __all__ = ("TestSQSBroker",) @@ -159,17 +159,34 @@ async def publish(self, cmd: "SQSPublishCommand") -> None: @override async def publish_batch(self, cmd: "SQSPublishCommand") -> None: - for body in cmd.batch_bodies: - await self.publish( - SQSPublishCommand( - body, - queue=cmd.destination, - headers=cmd.headers, + serializer = self.broker.config.fd_config._serializer + + if isinstance(self.codec, BatchCodecProto): + encoded = await self.codec.encode_batch(cmd.batch_bodies, serializer) + else: + encoded = [ + await self.codec.encode(body, serializer) for body in cmd.batch_bodies + ] + + for handler in self.subscribers: + if not _queue_matches(handler.queue, cmd.destination): + continue + + messages = [ + _raw_message_from_payload( + payload, + content_type, correlation_id=gen_cor_id(), - group_id=getattr(cmd, "group_id", None), - _publish_type=cmd.publish_type, - ), - ) + headers=cmd.headers, + ) + for payload, content_type in encoded + ] + + if handler._batch: + await handler.process_message(messages) + else: + for message in messages: + await handler.process_message(message) @override async def request(self, cmd: "SQSPublishCommand") -> Any: @@ -202,21 +219,15 @@ async def request(self, cmd: "SQSPublishCommand") -> Any: raise SubscriberNotFound -async def build_message( - message: "SendableMessage", - queue: str, +def _raw_message_from_payload( + payload: Any, + content_type: str | None, *, correlation_id: str | None = None, reply_to: str = "", headers: dict[str, str] | None = None, - serializer: Optional["SerializerProto"] = None, - codec: Optional["CodecProto"] = None, ) -> dict[str, Any]: - """Build a fake raw SQS message dict from publish parameters.""" - if codec is None: - codec = DefaultCodec() - payload, content_type = await codec.encode(message, serializer=serializer) - + """Build a fake raw SQS message dict from an already-encoded payload.""" base64_body = False if isinstance(payload, bytes): try: @@ -246,3 +257,27 @@ async def build_message( "Body": body, "MessageAttributes": attributes, } + + +async def build_message( + message: "SendableMessage", + queue: str, + *, + correlation_id: str | None = None, + reply_to: str = "", + headers: dict[str, str] | None = None, + serializer: Optional["SerializerProto"] = None, + codec: Optional["CodecProto"] = None, +) -> dict[str, Any]: + """Build a fake raw SQS message dict from publish parameters.""" + if codec is None: + codec = DefaultCodec() + payload, content_type = await codec.encode(message, serializer=serializer) + + return _raw_message_from_payload( + payload, + content_type, + correlation_id=correlation_id, + reply_to=reply_to, + headers=headers, + ) From 9807343f43eafae0333fe61562b9952a134e1016 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sat, 4 Jul 2026 21:14:59 +0300 Subject: [PATCH 14/19] feat(sqs): add subscriber concurrency and visibility-timeout heartbeat `max_workers` > 1 selects a ConcurrentSQSSubscriber that processes a poll's messages in parallel; it is rejected for batch=True and FIFO queues to preserve message-group ordering. `extend_visibility=True` runs a heartbeat that keeps extending a message's VisibilityTimeout while a long-running handler works, and requires an explicit visibility_timeout. --- faststream/sqs/broker/registrator.py | 9 ++++ faststream/sqs/broker/router.py | 4 ++ faststream/sqs/fastapi/fastapi.py | 4 ++ faststream/sqs/subscriber/config.py | 2 + faststream/sqs/subscriber/factory.py | 34 +++++++++++- faststream/sqs/subscriber/usecase.py | 77 ++++++++++++++++++++++++---- 6 files changed, 119 insertions(+), 11 deletions(-) diff --git a/faststream/sqs/broker/registrator.py b/faststream/sqs/broker/registrator.py index 033334a1ef3..3d72789876a 100644 --- a/faststream/sqs/broker/registrator.py +++ b/faststream/sqs/broker/registrator.py @@ -35,6 +35,8 @@ def subscriber( # type: ignore[override] visibility_timeout: int | None = None, batch: bool = False, request_attempt_id: str | None = None, + max_workers: int = 1, + extend_visibility: bool = False, # broker arguments ack_policy: AckPolicy = EMPTY, no_reply: bool = False, @@ -60,6 +62,11 @@ def subscriber( # type: ignore[override] receive-side deduplication token used to retry a failed ``ReceiveMessage`` call and get the same messages back. FIFO-only; a ``SetupError`` is raised if set for a non-FIFO queue. + max_workers: Number of concurrent handler tasks; ``>1`` selects a + concurrent subscriber. Not allowed with ``batch=True`` or FIFO + queues (concurrency would break message-group ordering). + extend_visibility: Keep extending the message's ``VisibilityTimeout`` + while the handler is running (requires ``visibility_timeout``). ack_policy: Acknowledgement policy for message processing. no_reply: Whether to disable FastStream RPC / reply-to responses. dependencies: Dependencies list to apply to the subscriber. @@ -78,6 +85,8 @@ def subscriber( # type: ignore[override] visibility_timeout=visibility_timeout, batch=batch, request_attempt_id=request_attempt_id, + max_workers=max_workers, + extend_visibility=extend_visibility, ack_policy=ack_policy, no_reply=no_reply, config=cast("SQSBrokerConfig", self.config), diff --git a/faststream/sqs/broker/router.py b/faststream/sqs/broker/router.py index d288b6cdc08..b3819565d46 100644 --- a/faststream/sqs/broker/router.py +++ b/faststream/sqs/broker/router.py @@ -66,6 +66,8 @@ def __init__( wait_time_seconds: int = 20, max_messages: int = 10, visibility_timeout: int | None = None, + max_workers: int = 1, + extend_visibility: bool = False, ack_policy: AckPolicy = EMPTY, no_reply: bool = False, dependencies: Iterable["Dependant"] = (), @@ -83,6 +85,8 @@ def __init__( wait_time_seconds=wait_time_seconds, max_messages=max_messages, visibility_timeout=visibility_timeout, + max_workers=max_workers, + extend_visibility=extend_visibility, ack_policy=ack_policy, no_reply=no_reply, dependencies=dependencies, diff --git a/faststream/sqs/fastapi/fastapi.py b/faststream/sqs/fastapi/fastapi.py index cf766570cff..36675be5622 100644 --- a/faststream/sqs/fastapi/fastapi.py +++ b/faststream/sqs/fastapi/fastapi.py @@ -157,6 +157,8 @@ def subscriber( # type: ignore[override] visibility_timeout: int | None = None, batch: bool = False, request_attempt_id: str | None = None, + max_workers: int = 1, + extend_visibility: bool = False, # broker arguments ack_policy: AckPolicy = EMPTY, no_reply: bool = False, @@ -187,6 +189,8 @@ def subscriber( # type: ignore[override] visibility_timeout=visibility_timeout, batch=batch, request_attempt_id=request_attempt_id, + max_workers=max_workers, + extend_visibility=extend_visibility, ack_policy=ack_policy, no_reply=no_reply, dependencies=dependencies, diff --git a/faststream/sqs/subscriber/config.py b/faststream/sqs/subscriber/config.py index 64182e04068..a92ed60eb20 100644 --- a/faststream/sqs/subscriber/config.py +++ b/faststream/sqs/subscriber/config.py @@ -30,6 +30,8 @@ class SQSSubscriberConfig(SubscriberUsecaseConfig): batch: bool = False # ReceiveRequestAttemptId — FIFO-only receive-retry dedup token. request_attempt_id: str | None = None + # Opt-in ChangeMessageVisibility heartbeat for long-running handlers. + extend_visibility: bool = False @property def ack_policy(self) -> AckPolicy: diff --git a/faststream/sqs/subscriber/factory.py b/faststream/sqs/subscriber/factory.py index 6f2a1def793..fb2a53e6dba 100644 --- a/faststream/sqs/subscriber/factory.py +++ b/faststream/sqs/subscriber/factory.py @@ -6,7 +6,7 @@ from .config import SQSSubscriberConfig, SQSSubscriberSpecificationConfig from .specification import SQSSubscriberSpecification -from .usecase import SQSSubscriber +from .usecase import ConcurrentSQSSubscriber, SQSSubscriber if TYPE_CHECKING: from faststream.middlewares import AckPolicy @@ -21,6 +21,8 @@ def create_subscriber( visibility_timeout: int | None, batch: bool, request_attempt_id: str | None, + max_workers: int, + extend_visibility: bool, # Subscriber args ack_policy: "AckPolicy", no_reply: bool, @@ -43,6 +45,27 @@ def create_subscriber( ) raise SetupError(msg) + if max_workers > 1 and batch: + msg = ( + "Can't combine `max_workers` with `batch=True` — a batch handler " + "already receives the whole poll (up to `max_messages` messages)." + ) + raise SetupError(msg) + + if max_workers > 1 and queue_name.endswith(".fifo"): + msg = ( + "`max_workers` can't be used with FIFO queues: concurrent processing " + "would break message-group ordering." + ) + raise SetupError(msg) + + if extend_visibility and visibility_timeout is None: + msg = ( + "`extend_visibility=True` requires an explicit `visibility_timeout` " + "so the heartbeat knows how far to extend it." + ) + raise SetupError(msg) + subscriber_config = SQSSubscriberConfig( queue=queue_name, declare=queue_obj, @@ -51,6 +74,7 @@ def create_subscriber( visibility_timeout=visibility_timeout, batch=batch, request_attempt_id=request_attempt_id, + extend_visibility=extend_visibility, no_reply=no_reply, _outer_config=config, _ack_policy=ack_policy, @@ -71,4 +95,12 @@ def create_subscriber( calls, ) + if max_workers > 1: + return ConcurrentSQSSubscriber( + subscriber_config, + specification, + calls, + max_workers=max_workers, + ) + return SQSSubscriber(subscriber_config, specification, calls) diff --git a/faststream/sqs/subscriber/usecase.py b/faststream/sqs/subscriber/usecase.py index fc3546708b3..f51b5792d6e 100644 --- a/faststream/sqs/subscriber/usecase.py +++ b/faststream/sqs/subscriber/usecase.py @@ -1,11 +1,14 @@ +import asyncio from collections.abc import AsyncIterator, Sequence -from typing import TYPE_CHECKING, Any +from contextlib import suppress +from typing import TYPE_CHECKING, Any, cast import anyio +from botocore.exceptions import BotoCoreError, ClientError from typing_extensions import override from faststream._internal.endpoint.subscriber import SubscriberUsecase -from faststream._internal.endpoint.subscriber.mixins import TasksMixin +from faststream._internal.endpoint.subscriber.mixins import ConcurrentMixin, TasksMixin from faststream._internal.endpoint.utils import process_msg from faststream.sqs.helpers import poll_with_backoff from faststream.sqs.parser import SQSParser @@ -44,6 +47,7 @@ def __init__( self._max_messages = config.max_messages self._visibility_timeout = config.visibility_timeout self._request_attempt_id = config.request_attempt_id + self._extend_visibility = config.extend_visibility self._queue_url: str = "" @property @@ -144,12 +148,6 @@ async def _dispatch(self, messages: list["SQSRawMessage"]) -> None: In batch mode the whole poll result is one ``consume`` call (the handler receives a list); otherwise each message is consumed individually. """ - # TODO: visibility-timeout heartbeat for long-running handlers. - # If a handler runs longer than the queue's VisibilityTimeout, the message - # becomes visible again and is redelivered (duplicate processing). For now - # the guidance is to raise `visibility_timeout` on the subscriber. A future - # enhancement could spawn a background task per in-flight message that calls - # `change_message_visibility` periodically (opt-in via e.g. `extend_visibility`). if self._batch: if messages: await self.consume(messages) @@ -157,12 +155,54 @@ async def _dispatch(self, messages: list["SQSRawMessage"]) -> None: for message in messages: await self.consume(message) + def _receipt_handles(self, msg: Any) -> list[str]: + raw_messages = msg if isinstance(msg, list) else [msg] + return [h for m in raw_messages if (h := m.get("ReceiptHandle"))] + + async def _heartbeat_loop(self, handles: list[str]) -> None: + """Periodically re-extend VisibilityTimeout while a handler runs.""" + assert self._visibility_timeout is not None + interval = max(self._visibility_timeout / 2, 1.0) + while True: + await asyncio.sleep(interval) + try: + for start in range(0, len(handles), 10): + chunk = handles[start : start + 10] + await self._outer_config.client.change_message_visibility_batch( + QueueUrl=self._queue_url, + Entries=[ + { + "Id": str(idx), + "ReceiptHandle": handle, + "VisibilityTimeout": self._visibility_timeout, + } + for idx, handle in enumerate(chunk) + ], + ) + except (ClientError, BotoCoreError): + # message already deleted / handle expired — stop extending + return + + @override + async def consume(self, msg: Any) -> Any: + if not self._extend_visibility: + return await super().consume(msg) + + handles = self._receipt_handles(msg) + heartbeat = asyncio.create_task(self._heartbeat_loop(handles)) + try: + return await super().consume(msg) + finally: + heartbeat.cancel() + with suppress(asyncio.CancelledError): + await heartbeat + @override async def get_one( self, *, timeout: float = 5.0, - ) -> "StreamMessage[SQSRawMessage] | None": + ) -> "SQSMessage | None": assert not self.calls, ( "You can't use `get_one` method if subscriber has registered handlers." ) @@ -189,12 +229,13 @@ async def get_one( async_parser, async_decoder = self._get_parser_and_decoder() # In batch mode the parser expects a list of raw messages. msg_input: Any = [raw_msg] if (self._batch and raw_msg is not None) else raw_msg - return await process_msg( + msg = await process_msg( msg=msg_input, middlewares=(m(msg_input, context=context) for m in self._broker_middlewares), parser=async_parser, decoder=async_decoder, ) + return cast("SQSMessage | None", msg) @override async def __aiter__(self) -> AsyncIterator["SQSMessage"]: # type: ignore[override] @@ -221,3 +262,19 @@ async def __aiter__(self) -> AsyncIterator["SQSMessage"]: # type: ignore[overri decoder=async_decoder, ) yield msg + + +class ConcurrentSQSSubscriber(ConcurrentMixin["SQSRawMessage"], SQSSubscriber): + """SQS subscriber processing polled messages concurrently (`max_workers` tasks).""" + + @override + async def start(self) -> None: + if self.calls: + self.start_consume_task() + await super().start() + + @override + async def _dispatch(self, messages: list["SQSRawMessage"]) -> None: + # batch=True is rejected by the factory, so messages are always individual. + for message in messages: + await self._put_msg(message) From c102017ca776125c25a0f8befb6ffa8e03eb6c58 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sat, 4 Jul 2026 21:15:07 +0300 Subject: [PATCH 15/19] test(sqs): cover concurrency, batch publish, ack policy, parser, and config Add SQS test modules for ack policy, codec, config, misconfiguration guards, parser, publish command, security, and TestBroker re-entrancy; extend the consume/publish/features suites for concurrency and batch publishing; and grow the mypy type-check fixture to match the other brokers. --- tests/brokers/sqs/test_ack_policy.py | 92 +++++++ tests/brokers/sqs/test_codec.py | 83 +++++++ tests/brokers/sqs/test_config.py | 76 ++++++ tests/brokers/sqs/test_consume.py | 76 ++++++ tests/brokers/sqs/test_features.py | 25 ++ tests/brokers/sqs/test_misconfigure.py | 43 ++++ tests/brokers/sqs/test_parser.py | 11 + tests/brokers/sqs/test_publish.py | 65 ++++- tests/brokers/sqs/test_publish_command.py | 31 +++ tests/brokers/sqs/test_security.py | 45 ++++ tests/brokers/sqs/test_test_reentrancy.py | 59 +++++ tests/mypy/sqs.py | 287 +++++++++++++++++++++- 12 files changed, 891 insertions(+), 2 deletions(-) create mode 100644 tests/brokers/sqs/test_ack_policy.py create mode 100644 tests/brokers/sqs/test_codec.py create mode 100644 tests/brokers/sqs/test_config.py create mode 100644 tests/brokers/sqs/test_misconfigure.py create mode 100644 tests/brokers/sqs/test_parser.py create mode 100644 tests/brokers/sqs/test_publish_command.py create mode 100644 tests/brokers/sqs/test_security.py create mode 100644 tests/brokers/sqs/test_test_reentrancy.py diff --git a/tests/brokers/sqs/test_ack_policy.py b/tests/brokers/sqs/test_ack_policy.py new file mode 100644 index 00000000000..8101c1e1014 --- /dev/null +++ b/tests/brokers/sqs/test_ack_policy.py @@ -0,0 +1,92 @@ +import asyncio +from unittest.mock import MagicMock + +import anyio +import pytest + +from faststream import AckPolicy +from faststream.sqs import SQSMessage + +from .basic import SQSTestcaseConfig + + +@pytest.mark.sqs() +@pytest.mark.connected() +@pytest.mark.asyncio() +class TestAckPolicy(SQSTestcaseConfig): + async def test_ack_deletes_message(self, queue: str, mock: MagicMock) -> None: + broker = self.get_broker() + done = asyncio.Event() + + @broker.subscriber( + queue, + ack_policy=AckPolicy.ACK, + visibility_timeout=1, + wait_time_seconds=1, + ) + async def handler(msg: str) -> None: + mock() + done.set() + + async with broker: + await broker.start() + await broker.publish("hello", queue) + with anyio.fail_after(self.timeout): + await done.wait() + # if ack had not deleted the message, visibility_timeout=1 + # would redeliver it within this window + await asyncio.sleep(3.0) + + assert mock.call_count == 1, mock.call_count + + async def test_nack_on_error_redelivers( + self, + queue: str, + mock: MagicMock, + ) -> None: + broker = self.get_broker() + done = asyncio.Event() + + @broker.subscriber( + queue, + ack_policy=AckPolicy.NACK_ON_ERROR, + visibility_timeout=1, + wait_time_seconds=1, + ) + async def handler(msg: str) -> None: + mock() + if mock.call_count == 1: + raise ValueError(msg) + done.set() + + async with broker: + await broker.start() + await broker.publish("hello", queue) + with anyio.fail_after(self.timeout): + await done.wait() + + assert mock.call_count == 2, mock.call_count + + async def test_manual_ack(self, queue: str, mock: MagicMock) -> None: + broker = self.get_broker(apply_types=True) + done = asyncio.Event() + + @broker.subscriber( + queue, + ack_policy=AckPolicy.MANUAL, + visibility_timeout=1, + wait_time_seconds=1, + ) + async def handler(body: str, msg: SQSMessage) -> None: + mock() + await msg.ack() + done.set() + + async with broker: + await broker.start() + await broker.publish("hello", queue) + with anyio.fail_after(self.timeout): + await done.wait() + await asyncio.sleep(3.0) + + assert mock.call_count == 1, mock.call_count diff --git a/tests/brokers/sqs/test_codec.py b/tests/brokers/sqs/test_codec.py new file mode 100644 index 00000000000..70b5d3aa5ae --- /dev/null +++ b/tests/brokers/sqs/test_codec.py @@ -0,0 +1,83 @@ +from collections.abc import Sequence +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from faststream._internal.parser import DefaultCodec +from tests.brokers.base.codec import BatchCodecTestcase, CodecTestcase + +from .basic import SQSMemoryTestcaseConfig + + +@pytest.mark.sqs() +@pytest.mark.asyncio() +class TestSQSCodec(SQSMemoryTestcaseConfig, CodecTestcase): + pass + + +@pytest.mark.sqs() +@pytest.mark.asyncio() +class TestSQSBatchCodec(SQSMemoryTestcaseConfig, BatchCodecTestcase): + # The base testcase publishes with the kafka-style `topic=` kwarg; + # SQS's `publish_batch` names its destination `queue=`. + async def test_batch_codec_decode_batch_called( + self, + mock: MagicMock, + queue: str, + ) -> None: + decode_batch_mock = MagicMock() + + class TrackingBatchCodec(DefaultCodec): + async def encode_batch( + self, + msgs: Sequence[Any], + serializer: Any = None, + ) -> list[tuple[bytes, str | None]]: + return [await DefaultCodec.encode(self, m, serializer) for m in msgs] + + async def decode_batch(self, msg: Any) -> list[Any]: + decode_batch_mock() + return [b.decode() if isinstance(b, bytes) else b for b in msg.body] + + codec = TrackingBatchCodec() + broker = self.get_broker(codec=codec) + + @broker.subscriber(queue, batch=True) + async def handle(m: list[str]) -> None: + mock(m) + + async with self.patch_broker(broker) as br: + await br.publish_batch("a", "b", "c", queue=queue) + + assert decode_batch_mock.called, "decode_batch was not called" + mock.assert_called_once_with(["a", "b", "c"]) + + async def test_batch_codec_encode_batch_called( + self, + queue: str, + ) -> None: + encode_batch_mock = MagicMock() + + class TrackingBatchCodec(DefaultCodec): + async def encode_batch( + self, + msgs: Sequence[Any], + serializer: Any = None, + ) -> list[tuple[bytes, str | None]]: + encode_batch_mock() + return [await DefaultCodec.encode(self, m, serializer) for m in msgs] + + async def decode_batch(self, msg: Any) -> list[Any]: + return [b.decode() if isinstance(b, bytes) else b for b in msg.body] + + broker = self.get_broker(codec=TrackingBatchCodec()) + + @broker.subscriber(queue, batch=True) + async def handle(m: list[str]) -> None: + pass + + async with self.patch_broker(broker) as br: + await br.publish_batch("a", "b", "c", queue=queue) + + assert encode_batch_mock.called, "encode_batch was not called on publish" diff --git a/tests/brokers/sqs/test_config.py b/tests/brokers/sqs/test_config.py new file mode 100644 index 00000000000..bbfd37e640a --- /dev/null +++ b/tests/brokers/sqs/test_config.py @@ -0,0 +1,76 @@ +from typing import Any + +import pytest + +from faststream import AckPolicy +from faststream.sqs import SQSBroker, SQSRouter +from faststream.sqs.schemas import SQSQueue +from faststream.sqs.subscriber.config import SQSSubscriberConfig + + +def make_config(**kwargs: Any) -> SQSSubscriberConfig: + return SQSSubscriberConfig( + queue="test", + declare=SQSQueue(name="test"), + **kwargs, + ) + + +@pytest.mark.sqs() +def test_default() -> None: + config = make_config() + + assert config.ack_policy is AckPolicy.ACK + + +@pytest.mark.sqs() +def test_custom_ack() -> None: + config = make_config(_ack_policy=AckPolicy.REJECT_ON_ERROR) + + assert config.ack_policy is AckPolicy.REJECT_ON_ERROR + + +@pytest.mark.sqs() +def test_broker_ack_policy() -> None: + broker = SQSBroker(ack_policy=AckPolicy.REJECT_ON_ERROR) + sub = broker.subscriber("test") + assert sub.ack_policy is AckPolicy.REJECT_ON_ERROR + + +@pytest.mark.sqs() +def test_router_ack_policy() -> None: + router = SQSRouter(ack_policy=AckPolicy.REJECT_ON_ERROR) + sub = router.subscriber("test") + assert sub.ack_policy is AckPolicy.REJECT_ON_ERROR + + +@pytest.mark.sqs() +def test_broker_ack_policy_with_router() -> None: + broker = SQSBroker(ack_policy=AckPolicy.REJECT_ON_ERROR) + router = SQSRouter() + broker.include_router(router) + sub = router.subscriber("test") + assert sub.ack_policy is AckPolicy.REJECT_ON_ERROR + + +@pytest.mark.sqs() +def test_router_overrides_broker() -> None: + broker = SQSBroker(ack_policy=AckPolicy.ACK) + router = SQSRouter(ack_policy=AckPolicy.REJECT_ON_ERROR) + broker.include_router(router) + sub = router.subscriber("test") + assert sub.ack_policy is AckPolicy.REJECT_ON_ERROR + + +@pytest.mark.sqs() +def test_sub_overrides_broker() -> None: + broker = SQSBroker(ack_policy=AckPolicy.REJECT_ON_ERROR) + sub = broker.subscriber("test", ack_policy=AckPolicy.ACK) + assert sub.ack_policy is AckPolicy.ACK + + +@pytest.mark.sqs() +def test_sub_overrides_router() -> None: + router = SQSRouter(ack_policy=AckPolicy.REJECT_ON_ERROR) + sub = router.subscriber("test", ack_policy=AckPolicy.ACK) + assert sub.ack_policy is AckPolicy.ACK diff --git a/tests/brokers/sqs/test_consume.py b/tests/brokers/sqs/test_consume.py index 9dcc2a4a3a7..392abf6d50d 100644 --- a/tests/brokers/sqs/test_consume.py +++ b/tests/brokers/sqs/test_consume.py @@ -1,6 +1,7 @@ import asyncio from unittest.mock import MagicMock, patch +import anyio import pytest from faststream import AckPolicy @@ -47,3 +48,78 @@ async def handler(body: str) -> None: assert event.is_set() mock.assert_called_once_with("hello") + + @pytest.mark.asyncio() + async def test_concurrent_subscriber( + self, + queue: str, + mock: MagicMock, + ) -> None: + event = asyncio.Event() + event2 = asyncio.Event() + + broker = self.get_broker() + + args, kwargs = self.get_subscriber_params(queue, max_workers=2) + + @broker.subscriber(*args, **kwargs) + async def handler(msg) -> None: + mock() + + if event.is_set(): + event2.set() + else: + event.set() + + await asyncio.sleep(3.0) + + async with self.patch_broker(broker) as br: + await br.start() + + for i in range(5): + await br.publish(i, queue) + + await asyncio.wait( + ( + asyncio.create_task(event.wait()), + asyncio.create_task(event2.wait()), + ), + timeout=self.timeout, + ) + + assert event.is_set() + assert event2.is_set() + assert mock.call_count == 2, mock.call_count + + @pytest.mark.asyncio() + async def test_extend_visibility_prevents_redelivery( + self, + queue: str, + mock: MagicMock, + ) -> None: + broker = self.get_broker() + done = asyncio.Event() + + args, kwargs = self.get_subscriber_params( + queue, + visibility_timeout=2, + extend_visibility=True, + wait_time_seconds=1, + ) + + @broker.subscriber(*args, **kwargs) + async def handler(msg: str) -> None: + mock() + # run well past visibility_timeout: without the heartbeat SQS + # would redeliver and mock.call_count would grow + await asyncio.sleep(5.0) + done.set() + + async with broker: + await broker.start() + await broker.publish("hello", queue) + with anyio.fail_after(self.timeout): + await done.wait() + await asyncio.sleep(3.0) + + assert mock.call_count == 1, mock.call_count diff --git a/tests/brokers/sqs/test_features.py b/tests/brokers/sqs/test_features.py index e208448d06b..56645997fce 100644 --- a/tests/brokers/sqs/test_features.py +++ b/tests/brokers/sqs/test_features.py @@ -429,3 +429,28 @@ async def handler(msg: str) -> None: assert event.is_set() assert received == ["ordered"] + + +@pytest.mark.sqs() +class TestQueueAttributes: + def test_policy_attribute_rendered_as_json(self) -> None: + import json + + from faststream.sqs import SQSQueue + + policy = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Principal": "*", "Action": "sqs:SendMessage"}, + ], + } + queue = SQSQueue(name="q", policy=policy) + + attrs = queue.to_attributes() + + assert json.loads(attrs["Policy"]) == policy + + def test_policy_absent_by_default(self) -> None: + from faststream.sqs import SQSQueue + + assert "Policy" not in SQSQueue(name="q").to_attributes() diff --git a/tests/brokers/sqs/test_misconfigure.py b/tests/brokers/sqs/test_misconfigure.py new file mode 100644 index 00000000000..fd6f5adfc80 --- /dev/null +++ b/tests/brokers/sqs/test_misconfigure.py @@ -0,0 +1,43 @@ +import pytest + +from faststream.exceptions import SetupError +from faststream.nats import NatsRouter +from faststream.sqs import FifoQueue, SQSBroker +from faststream.sqs.subscriber.usecase import ConcurrentSQSSubscriber + + +@pytest.mark.sqs() +def test_max_workers_selects_concurrent_subscriber() -> None: + sub = SQSBroker().subscriber("queue", max_workers=3) + assert isinstance(sub, ConcurrentSQSSubscriber) + + +@pytest.mark.sqs() +def test_max_workers_with_batch_forbidden() -> None: + with pytest.raises(SetupError): + SQSBroker().subscriber("queue", batch=True, max_workers=3) + + +@pytest.mark.sqs() +def test_max_workers_with_fifo_forbidden() -> None: + with pytest.raises(SetupError): + SQSBroker().subscriber(FifoQueue(name="queue"), max_workers=3) + + +@pytest.mark.sqs() +def test_request_attempt_id_non_fifo_forbidden() -> None: + with pytest.raises(SetupError): + SQSBroker().subscriber("queue", request_attempt_id="id") + + +@pytest.mark.sqs() +def test_extend_visibility_requires_visibility_timeout() -> None: + with pytest.raises(SetupError): + SQSBroker().subscriber("queue", extend_visibility=True) + + +@pytest.mark.sqs() +def test_use_only_sqs_router() -> None: + broker = SQSBroker() + with pytest.raises(SetupError): + broker.include_router(NatsRouter()) diff --git a/tests/brokers/sqs/test_parser.py b/tests/brokers/sqs/test_parser.py new file mode 100644 index 00000000000..1b0df17fb21 --- /dev/null +++ b/tests/brokers/sqs/test_parser.py @@ -0,0 +1,11 @@ +import pytest + +from tests.brokers.base.parser import CustomParserTestcase + +from .basic import SQSTestcaseConfig + + +@pytest.mark.sqs() +@pytest.mark.connected() +class TestCustomParser(SQSTestcaseConfig, CustomParserTestcase): + pass diff --git a/tests/brokers/sqs/test_publish.py b/tests/brokers/sqs/test_publish.py index de64950f62d..d099ea742e6 100644 --- a/tests/brokers/sqs/test_publish.py +++ b/tests/brokers/sqs/test_publish.py @@ -1,13 +1,14 @@ import asyncio from unittest.mock import MagicMock +import anyio import pytest from faststream import Context from faststream.sqs import SQSResponse from tests.brokers.base.publish import BrokerPublishTestcase -from .basic import SQSTestcaseConfig +from .basic import SQSMemoryTestcaseConfig, SQSTestcaseConfig @pytest.mark.connected() @@ -43,3 +44,65 @@ async def handle_next(msg=Context("message")) -> None: assert event.is_set() mock.assert_called_once_with(body=b"1", correlation_id="1") + + @pytest.mark.asyncio() + async def test_batch_publisher_real(self, queue: str) -> None: + broker = self.get_broker(apply_types=True) + publisher = broker.publisher(queue, batch=True) + + collected: list[int] = [] + done = asyncio.Event() + + @broker.subscriber(queue) + async def handler(msg: int) -> None: + collected.append(msg) + if len(collected) == 3: + done.set() + + async with broker: + await broker.start() + await publisher.publish(1, 2, 3) + with anyio.fail_after(self.timeout): + await done.wait() + + assert sorted(collected) == [1, 2, 3] + + +@pytest.mark.sqs() +@pytest.mark.asyncio() +class TestBatchPublisher(SQSMemoryTestcaseConfig): + async def test_batch_publisher_publishes_each_message(self, queue: str) -> None: + broker = self.get_broker(apply_types=True) + publisher = broker.publisher(queue, batch=True) + + received: list[int] = [] + + @broker.subscriber(queue) + async def handler(msg: int) -> None: + received.append(msg) + + async with self.patch_broker(broker) as br: + await br.start() + await publisher.publish(1, 2, 3) + + assert sorted(received) == [1, 2, 3] + + async def test_batch_publisher_decorator(self, queue: str) -> None: + broker = self.get_broker(apply_types=True) + + received: list[int] = [] + + @broker.subscriber(queue + "out") + async def sink(msg: int) -> None: + received.append(msg) + + @broker.publisher(queue + "out", batch=True) + @broker.subscriber(queue) + async def handler(msg: int) -> list[int]: + return [msg, msg + 1] + + async with self.patch_broker(broker) as br: + await br.start() + await br.publish(1, queue) + + assert sorted(received) == [1, 2] diff --git a/tests/brokers/sqs/test_publish_command.py b/tests/brokers/sqs/test_publish_command.py new file mode 100644 index 00000000000..fed47755da4 --- /dev/null +++ b/tests/brokers/sqs/test_publish_command.py @@ -0,0 +1,31 @@ +import pytest + +from faststream.response import ensure_response +from faststream.sqs.response import ( + SQSBatchPublishCommand, + SQSPublishCommand, + SQSResponse, +) +from tests.brokers.base.publish_command import BatchPublishCommandTestcase + + +@pytest.mark.sqs() +class TestPublishCommand(BatchPublishCommandTestcase): + publish_command_cls = SQSBatchPublishCommand + + def test_sqs_response_class(self) -> None: + response = ensure_response( + SQSResponse( + body=1, + headers={"1": "1"}, + group_id="g", + deduplication_id="d", + delay_seconds=3, + ), + ) + cmd = SQSPublishCommand.from_cmd(response.as_publish_command()) + assert cmd.body == 1 + assert cmd.headers == {"1": "1"} + assert cmd.group_id == "g" + assert cmd.deduplication_id == "d" + assert cmd.delay_seconds == 3 diff --git a/tests/brokers/sqs/test_security.py b/tests/brokers/sqs/test_security.py new file mode 100644 index 00000000000..88fcca856f8 --- /dev/null +++ b/tests/brokers/sqs/test_security.py @@ -0,0 +1,45 @@ +from typing import Any + +import pytest + +from faststream.security import BaseSecurity, SASLPlaintext, SASLScram256 +from faststream.sqs.security import parse_security + + +@pytest.mark.sqs() +def test_parse_no_security() -> None: + assert parse_security(None) == {} + + +@pytest.mark.sqs() +@pytest.mark.parametrize( + ("security", "expected"), + ( + pytest.param(BaseSecurity(use_ssl=True), {"use_ssl": True}, id="ssl"), + pytest.param(BaseSecurity(use_ssl=False), {"use_ssl": False}, id="no ssl"), + pytest.param(BaseSecurity(), {"use_ssl": False}, id="default"), + pytest.param( + SASLPlaintext(username="key", password="secret", use_ssl=True), + { + "use_ssl": True, + "aws_access_key_id": "key", + "aws_secret_access_key": "secret", + }, + id="credentials", + ), + ), +) +def test_parse_security(security: BaseSecurity, expected: dict[str, Any]) -> None: + assert parse_security(security) == expected + + +@pytest.mark.sqs() +def test_unsupported_subclass_falls_back_to_base_security() -> None: + """Unknown ``BaseSecurity`` subclasses degrade to the plain TLS mapping. + + Same convention as the MQTT broker: only ``SASLPlaintext`` carries + credentials; anything else contributes ``use_ssl`` only. + """ + parsed = parse_security(SASLScram256(username="u", password="p")) + + assert parsed == {"use_ssl": False} diff --git a/tests/brokers/sqs/test_test_reentrancy.py b/tests/brokers/sqs/test_test_reentrancy.py new file mode 100644 index 00000000000..5f2944bcdac --- /dev/null +++ b/tests/brokers/sqs/test_test_reentrancy.py @@ -0,0 +1,59 @@ +import pytest + +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() + + +to_output_data = broker.publisher("output_data") + + +@to_output_data +@broker.subscriber("input_data") +async def on_input_data(msg: int) -> int: + return msg + 1 + + +@broker.subscriber("output_data") +async def on_output_data(msg: int) -> None: + pass + + +async def _test_with_broker(with_real: bool) -> None: + async with TestSQSBroker(broker, with_real=with_real) as tester: + await tester.publish(1, "input_data") + + await on_output_data.wait_call(3) + + on_input_data.mock.assert_called_once_with(1) + to_output_data.mock.assert_called_once_with(2) + on_output_data.mock.assert_called_once_with(2) + + +@pytest.mark.sqs() +@pytest.mark.asyncio() +async def test_with_fake_broker() -> None: + await _test_with_broker(False) + await _test_with_broker(False) + + +async def _test_with_temp_subscriber() -> None: + @broker.subscriber("output_data") + async def on_output_data(msg: int) -> None: + pass + + async with TestSQSBroker(broker) as tester: + await tester.publish(1, "input_data") + + await on_output_data.wait_call(3) + + on_input_data.mock.assert_called_once_with(1) + to_output_data.mock.assert_called_once_with(2) + on_output_data.mock.assert_called_once_with(2) + + +@pytest.mark.sqs() +@pytest.mark.asyncio() +async def test_with_temp_subscriber() -> None: + await _test_with_temp_subscriber() + await _test_with_temp_subscriber() diff --git a/tests/mypy/sqs.py b/tests/mypy/sqs.py index 1242f5a3993..fcbc793725b 100644 --- a/tests/mypy/sqs.py +++ b/tests/mypy/sqs.py @@ -1,6 +1,22 @@ +from collections.abc import Awaitable, Callable + +import prometheus_client from typing_extensions import assert_type -from faststream.sqs import SQSBroker, SQSMessage, TestSQSBroker +from faststream._internal.basic_types import DecodedMessage +from faststream.sqs import ( + SQSBroker, + SQSMessage, + SQSRoute, + SQSRouter, + TestSQSBroker, +) +from faststream.sqs.fastapi import SQSRouter as FastAPIRouter +from faststream.sqs.message import SQSRawMessage +from faststream.sqs.opentelemetry import SQSTelemetryMiddleware +from faststream.sqs.prometheus import SQSPrometheusMiddleware +from faststream.sqs.publisher.usecase import SQSBatchPublisher, SQSDefaultPublisher +from faststream.sqs.subscriber.usecase import ConcurrentSQSSubscriber, SQSSubscriber async def check_multiple_test_brokers() -> None: @@ -16,8 +32,277 @@ async def check_multiple_test_brokers() -> None: await br2.publish(None, "test") +def sync_decoder(msg: SQSMessage) -> DecodedMessage: + return "" + + +async def async_decoder(msg: SQSMessage) -> DecodedMessage: + return "" + + +async def custom_decoder( + msg: SQSMessage, + original: Callable[[SQSMessage], Awaitable[DecodedMessage]], +) -> DecodedMessage: + return await original(msg) + + +SQSBroker(decoder=sync_decoder) +SQSBroker(decoder=async_decoder) +SQSBroker(decoder=custom_decoder) + + +def sync_parser(msg: SQSRawMessage) -> SQSMessage: + return "" # type: ignore[return-value] + + +async def async_parser(msg: SQSRawMessage) -> SQSMessage: + return "" # type: ignore[return-value] + + +async def custom_parser( + msg: SQSRawMessage, + original: Callable[[SQSRawMessage], Awaitable[SQSMessage]], +) -> SQSMessage: + return await original(msg) + + +SQSBroker(parser=sync_parser) +SQSBroker(parser=async_parser) +SQSBroker(parser=custom_parser) + + +def sync_filter(msg: SQSMessage) -> bool: + return True + + +async def async_filter(msg: SQSMessage) -> bool: + return True + + +broker = SQSBroker() + + +sub = broker.subscriber("test") + + +@sub( + filter=sync_filter, +) +async def handle() -> None: ... + + +@sub( + filter=async_filter, +) +async def handle2() -> None: ... + + +@broker.subscriber( + "test", + parser=sync_parser, + decoder=sync_decoder, +) +async def handle3() -> None: ... + + +@broker.subscriber( + "test", + parser=async_parser, + decoder=async_decoder, +) +async def handle4() -> None: ... + + +@broker.subscriber( + "test", + parser=custom_parser, + decoder=custom_decoder, +) +async def handle5() -> None: ... + + +@broker.subscriber("test") +@broker.publisher("test2") +def handle6() -> None: ... + + +@broker.subscriber("test") +@broker.publisher("test2") +async def handle7() -> None: ... + + +SQSRouter( + parser=sync_parser, + decoder=sync_decoder, +) +SQSRouter( + parser=async_parser, + decoder=async_decoder, +) +SQSRouter( + parser=custom_parser, + decoder=custom_decoder, +) + +router = SQSRouter() + +router_sub = router.subscriber("test") + + +@router_sub( + filter=sync_filter, +) +async def handle8() -> None: ... + + +@router_sub( + filter=async_filter, +) +async def handle9() -> None: ... + + +@router.subscriber("test") +@router.publisher("test2") +def handle10() -> None: ... + + +@router.subscriber("test") +@router.publisher("test2") +async def handle11() -> None: ... + + +def sync_handler() -> None: ... + + +async def async_handler() -> None: ... + + +SQSRouter( + handlers=( + SQSRoute(sync_handler, "test"), + SQSRoute(async_handler, "test"), + SQSRoute( + sync_handler, + "test", + parser=sync_parser, + decoder=sync_decoder, + ), + SQSRoute( + sync_handler, + "test", + parser=async_parser, + decoder=async_decoder, + ), + SQSRoute( + sync_handler, + "test", + parser=custom_parser, + decoder=custom_decoder, + ), + ), +) + + +FastAPIRouter( + parser=sync_parser, + decoder=sync_decoder, +) +FastAPIRouter( + parser=async_parser, + decoder=async_decoder, +) +FastAPIRouter( + parser=custom_parser, + decoder=custom_decoder, +) + +fastapi_router = FastAPIRouter() + +fastapi_sub = fastapi_router.subscriber("test") + + +@fastapi_sub( + filter=sync_filter, +) +async def handle12() -> None: ... + + +@fastapi_router.subscriber("test") +@fastapi_router.publisher("test2") +async def handle13() -> None: ... + + +otlp_middleware = SQSTelemetryMiddleware() +SQSBroker().add_middleware(otlp_middleware) +SQSBroker(middlewares=[otlp_middleware]) + + +prometheus_middleware = SQSPrometheusMiddleware(registry=prometheus_client.REGISTRY) +SQSBroker().add_middleware(prometheus_middleware) +SQSBroker(middlewares=[prometheus_middleware]) + + async def check_request_response_type() -> None: broker = SQSBroker() broker_response = await broker.request(None, "test") assert_type(broker_response, SQSMessage) + + publisher = broker.publisher("test") + publisher_response = await publisher.request(None, "test") + assert_type(publisher_response, SQSMessage) + + +async def check_subscriber_message_type( + broker: SQSBroker | SQSRouter, +) -> None: + subscriber = broker.subscriber("test") + + message = await subscriber.get_one() + assert_type(message, SQSMessage | None) + + async for msg in subscriber: + assert_type(msg, SQSMessage) + + +def check_subscriber_instance_type( + broker: SQSBroker | SQSRouter, +) -> None: + sub1 = broker.subscriber("test") + assert_type(sub1, SQSSubscriber) + + sub2 = broker.subscriber("test", max_workers=2) + concurrent: ConcurrentSQSSubscriber | SQSSubscriber = sub2 + assert isinstance(concurrent, SQSSubscriber) + + +def check_publisher_instance_type( + broker: SQSBroker | SQSRouter, +) -> None: + pub1 = broker.publisher("test") + assert_type(pub1, SQSDefaultPublisher) + + pub2 = broker.publisher("test", batch=True) + assert_type(pub2, SQSBatchPublisher) + + +async def check_publish_types() -> None: + broker = SQSBroker() + + await broker.publish("body", "queue") + await broker.publish(b"body", "queue", group_id="g", deduplication_id="d") + await broker.publish_batch("a", "b", queue="queue") + + publisher = broker.publisher("queue", batch=True) + await publisher.publish(1, 2, 3) + await publisher.publish(1, 2, 3, queue="other", group_id="g") + + +SQSBroker(routers=[SQSRouter()]) +SQSBroker().include_router(SQSRouter()) +SQSBroker().include_routers(SQSRouter()) + +SQSRouter(routers=[SQSRouter()]) +SQSRouter().include_router(SQSRouter()) +SQSRouter().include_routers(SQSRouter()) From 4807b8c77f886cb95c055d3debd6285584a0931e Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sat, 4 Jul 2026 21:15:13 +0300 Subject: [PATCH 16/19] docs(sqs): document concurrency, long-running handlers, and batch publisher Add subscription docs for max_workers concurrency and extend_visibility long-running handlers, and a batch-publisher publishing example, each with a tested docs_src snippet. --- docs/docs/en/sqs/publishing.md | 10 ++++++- docs/docs/en/sqs/subscription.md | 29 +++++++++++++++++++ .../sqs/publishing/batch_publisher.py | 24 +++++++++++++++ docs/docs_src/sqs/subscription/concurrency.py | 18 ++++++++++++ .../docs_src/sqs/subscription/long_running.py | 22 ++++++++++++++ tests/docs/sqs/publishing/test_publishing.py | 2 ++ .../sqs/subscription/test_subscription.py | 10 ++++++- 7 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 docs/docs_src/sqs/publishing/batch_publisher.py create mode 100644 docs/docs_src/sqs/subscription/concurrency.py create mode 100644 docs/docs_src/sqs/subscription/long_running.py diff --git a/docs/docs/en/sqs/publishing.md b/docs/docs/en/sqs/publishing.md index 0bce776770d..75420a004c8 100644 --- a/docs/docs/en/sqs/publishing.md +++ b/docs/docs/en/sqs/publishing.md @@ -41,4 +41,12 @@ Register a reusable publisher with `@broker.publisher`: {! docs_src/sqs/publishing/batch.py [ln:16] !} ``` -A single `SendMessageBatch` request carries up to 10 messages. +A single `SendMessageBatch` request carries up to 10 messages; larger batches +are split into several requests automatically. + +You can also declare a reusable batch publisher — every `publish(*messages)` +call becomes one `SendMessageBatch` request: + +```python linenums="1" +{! docs_src/sqs/publishing/batch_publisher.py [ln:9,16-17] !} +``` diff --git a/docs/docs/en/sqs/subscription.md b/docs/docs/en/sqs/subscription.md index 24072a8c5a7..382ae2130f4 100644 --- a/docs/docs/en/sqs/subscription.md +++ b/docs/docs/en/sqs/subscription.md @@ -26,3 +26,32 @@ Pass an `SQSQueue`/`FifoQueue` to create the queue automatically on startup: ```python linenums="1" {! docs_src/sqs/subscription/declared_queue.py [ln:4,10-11] !} ``` + +## Concurrent processing + +By default messages from one poll are processed sequentially. Set `max_workers` +to handle up to that many messages at the same time: + +```python linenums="1" +{! docs_src/sqs/subscription/concurrency.py [ln:10-11] !} +``` + +!!! warning + `max_workers` can't be combined with `batch=True` or FIFO queues — + concurrent processing would break message-group ordering. + +## Long-running handlers + +If a handler runs longer than the queue's `VisibilityTimeout`, SQS redelivers +the message while the first consumer is still working. Enable +`extend_visibility` to keep extending the timeout with a background heartbeat +until the handler finishes: + +```python linenums="1" +{! docs_src/sqs/subscription/long_running.py [ln:10-15] !} +``` + +!!! note + `extend_visibility=True` requires an explicit `visibility_timeout` — the + heartbeat re-extends the message by that value every `visibility_timeout / 2` + seconds. diff --git a/docs/docs_src/sqs/publishing/batch_publisher.py b/docs/docs_src/sqs/publishing/batch_publisher.py new file mode 100644 index 00000000000..397e5b13e6b --- /dev/null +++ b/docs/docs_src/sqs/publishing/batch_publisher.py @@ -0,0 +1,24 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + +publisher = broker.publisher("target-queue", batch=True) + + +@broker.subscriber("target-queue") +async def handler(msg: int) -> None: ... + + +async def send() -> None: + await publisher.publish(1, 2, 3) # one SendMessageBatch request + + +@pytest.mark.asyncio +async def test_batch_publisher() -> None: + async with TestSQSBroker(broker): + await publisher.publish(1, 2, 3) + assert handler.mock.call_count == 3 diff --git a/docs/docs_src/sqs/subscription/concurrency.py b/docs/docs_src/sqs/subscription/concurrency.py new file mode 100644 index 00000000000..95c1ba307a6 --- /dev/null +++ b/docs/docs_src/sqs/subscription/concurrency.py @@ -0,0 +1,18 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + + +@broker.subscriber("jobs", max_workers=5) +async def handler(msg: str) -> None: ... + + +@pytest.mark.asyncio +async def test_concurrency() -> None: + async with TestSQSBroker(broker): + await broker.publish("job", "jobs") + handler.mock.assert_called_once_with("job") diff --git a/docs/docs_src/sqs/subscription/long_running.py b/docs/docs_src/sqs/subscription/long_running.py new file mode 100644 index 00000000000..cc297db30c9 --- /dev/null +++ b/docs/docs_src/sqs/subscription/long_running.py @@ -0,0 +1,22 @@ +import pytest + +from faststream import FastStream +from faststream.sqs import SQSBroker, TestSQSBroker + +broker = SQSBroker() +app = FastStream(broker) + + +@broker.subscriber( + "long-jobs", + visibility_timeout=60, # message stays invisible for 60s per receive + extend_visibility=True, # keep extending it while the handler runs +) +async def handler(msg: str) -> None: ... + + +@pytest.mark.asyncio +async def test_long_running() -> None: + async with TestSQSBroker(broker): + await broker.publish("job", "long-jobs") + handler.mock.assert_called_once_with("job") diff --git a/tests/docs/sqs/publishing/test_publishing.py b/tests/docs/sqs/publishing/test_publishing.py index 83368308fc7..d662d3c110d 100644 --- a/tests/docs/sqs/publishing/test_publishing.py +++ b/tests/docs/sqs/publishing/test_publishing.py @@ -1,10 +1,12 @@ from docs.docs_src.sqs.publishing.batch import test_batch +from docs.docs_src.sqs.publishing.batch_publisher import test_batch_publisher from docs.docs_src.sqs.publishing.declared_queue import test_declared_queue from docs.docs_src.sqs.publishing.publish import test_publish from docs.docs_src.sqs.publishing.publisher_object import test_publisher_object __all__ = [ "test_batch", + "test_batch_publisher", "test_declared_queue", "test_publish", "test_publisher_object", diff --git a/tests/docs/sqs/subscription/test_subscription.py b/tests/docs/sqs/subscription/test_subscription.py index 214a0fe4d4e..6818a65af0f 100644 --- a/tests/docs/sqs/subscription/test_subscription.py +++ b/tests/docs/sqs/subscription/test_subscription.py @@ -1,5 +1,13 @@ from docs.docs_src.sqs.subscription.basic import test_subscription +from docs.docs_src.sqs.subscription.concurrency import test_concurrency from docs.docs_src.sqs.subscription.declared_queue import test_declared_queue +from docs.docs_src.sqs.subscription.long_running import test_long_running from docs.docs_src.sqs.subscription.message_info import test_message_info -__all__ = ["test_declared_queue", "test_message_info", "test_subscription"] +__all__ = [ + "test_concurrency", + "test_declared_queue", + "test_long_running", + "test_message_info", + "test_subscription", +] From 3b7fa90fca97b1b222087fcce4ae4c9fb00299a4 Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sat, 4 Jul 2026 21:37:01 +0300 Subject: [PATCH 17/19] refactor(sqs): scope test support to SQS, keep shared framework untouched --- faststream/_internal/testing/ast.py | 20 ++++--- faststream/_internal/testing/broker.py | 9 +-- .../asgi/factories/asyncapi/try_it_out.py | 13 ++++- faststream/confluent/testing.py | 58 +++++++++---------- faststream/kafka/testing.py | 58 +++++++++---------- faststream/mqtt/testing.py | 58 +++++++++---------- faststream/nats/testing.py | 58 +++++++++---------- faststream/rabbit/testing.py | 58 +++++++++---------- faststream/redis/testing.py | 58 +++++++++---------- faststream/sqs/testing.py | 58 +++++++++---------- 10 files changed, 227 insertions(+), 221 deletions(-) diff --git a/faststream/_internal/testing/ast.py b/faststream/_internal/testing/ast.py index baf0ad15657..baee68281bc 100644 --- a/faststream/_internal/testing/ast.py +++ b/faststream/_internal/testing/ast.py @@ -7,14 +7,18 @@ def is_contains_context_name(scip_name: str, name: str) -> bool: - # ``[-3]``: the user frame that opened the ``with`` block, right above - # ``TestBroker.__init__``/``TestApp.__init__`` and this helper. Concrete - # ``TestBroker`` subclasses must not override ``__init__`` at runtime - # (their typing overloads are ``TYPE_CHECKING``-only), or this offset - # would land on the wrong frame. - stack = traceback.extract_stack()[-3] - tree = _read_source_ast(stack.filename) - node = cast("ast.With | ast.AsyncWith", _find_ast_node(tree, stack.lineno)) + stack = traceback.extract_stack() + # Walk out past this frame and the ``TestBroker.__init__`` chain (the + # abstract base plus the concrete subclass override) to the user frame + # that opened the ``with`` block, regardless of how many ``__init__`` + # frames sit in between. + idx = len(stack) - 2 + while idx > 0 and stack[idx].name == "__init__": + idx -= 1 + frame = stack[idx] + + tree = _read_source_ast(frame.filename) + node = cast("ast.With | ast.AsyncWith", _find_ast_node(tree, frame.lineno)) context_calls = _get_withitem_calls(node) try: diff --git a/faststream/_internal/testing/broker.py b/faststream/_internal/testing/broker.py index 5d45c08f7fb..dcee3925b44 100644 --- a/faststream/_internal/testing/broker.py +++ b/faststream/_internal/testing/broker.py @@ -27,11 +27,7 @@ Broker = TypeVar("Broker", bound=BrokerUsecase[Any, Any]) # ``__aenter__`` return type. Each concrete ``TestBroker`` subclass binds it to a -# single broker or a ``tuple`` of brokers via ``TYPE_CHECKING``-only ``__init__`` -# overloads (mypy can't infer a subclass type argument from overloads declared on -# this base, so each subclass carries its own copy; keeping them type-only leaves -# the runtime stack free of extra ``__init__`` frames, which the AST-based -# ``connect_only`` detection relies on). +# single broker or a ``tuple`` of brokers via its overloaded ``__init__``. EnterType = TypeVar313("EnterType", default=Any) @@ -90,8 +86,7 @@ async def __aenter__(self) -> EnterType: brokers = await self._ctx.__aenter__() if len(brokers) == 1: return cast("EnterType", brokers[0]) - # a tuple, matching the multi-broker ``__init__`` overload declarations - return cast("EnterType", tuple(brokers)) + return cast("EnterType", brokers) async def __aexit__( self, diff --git a/faststream/asgi/factories/asyncapi/try_it_out.py b/faststream/asgi/factories/asyncapi/try_it_out.py index 4eae23c51de..471f5fd5736 100644 --- a/faststream/asgi/factories/asyncapi/try_it_out.py +++ b/faststream/asgi/factories/asyncapi/try_it_out.py @@ -1,7 +1,7 @@ -from collections.abc import Iterable, Sequence +from collections.abc import Callable, Iterable, Sequence from contextlib import suppress from functools import lru_cache -from typing import TYPE_CHECKING, Any, TypedDict, Union +from typing import TYPE_CHECKING, Any, TypedDict, Union, cast from faststream.asgi.annotations import Request from faststream.asgi.handlers import PostHandler, post @@ -112,7 +112,14 @@ async def process(self, body: TryItOutForm) -> AsgiResponse: if cls is test_broker_cls and b is not broker ), ) - async with test_broker_cls(*same_type_brokers): + # ``test_broker_cls`` is typed as the abstract ``TestBroker`` base, whose + # overloaded ``__init__`` would make mypy try to instantiate the abstract + # class. At runtime it is always a concrete subclass; the enter result is + # unused here, so call it through a plain factory type. + test_broker_factory = cast( + "Callable[..., TestBroker[Any, Any]]", test_broker_cls + ) + async with test_broker_factory(*same_type_brokers): data = await broker.request(payload, destination, timeout=30) decoded = None with suppress(Exception): diff --git a/faststream/confluent/testing.py b/faststream/confluent/testing.py index 45b9f231640..d637f43ce32 100644 --- a/faststream/confluent/testing.py +++ b/faststream/confluent/testing.py @@ -39,35 +39,35 @@ class TestKafkaBroker(TestBroker[KafkaBroker, EnterType]): """A class to test Kafka brokers.""" - # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker - # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the - # AST-based ``connect_only`` detection in ``TestBroker`` relies on. - if TYPE_CHECKING: - - @overload - def __init__( - self: "TestKafkaBroker[KafkaBroker]", - broker: KafkaBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestKafkaBroker[tuple[KafkaBroker, ...]]", - *brokers: KafkaBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: KafkaBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... + @overload + def __init__( + self: "TestKafkaBroker[KafkaBroker]", + broker: KafkaBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestKafkaBroker[tuple[KafkaBroker, ...]]", + *brokers: KafkaBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: KafkaBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: + super().__init__( + *brokers, + with_real=with_real, + connect_only=connect_only, + ) @contextmanager def _patch_producer(self, broker: KafkaBroker) -> Iterator[None]: diff --git a/faststream/kafka/testing.py b/faststream/kafka/testing.py index eb24fc48db6..c8c1ef7dc82 100755 --- a/faststream/kafka/testing.py +++ b/faststream/kafka/testing.py @@ -41,35 +41,35 @@ class TestKafkaBroker(TestBroker[KafkaBroker, EnterType]): """A class to test Kafka brokers.""" - # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker - # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the - # AST-based ``connect_only`` detection in ``TestBroker`` relies on. - if TYPE_CHECKING: - - @overload - def __init__( - self: "TestKafkaBroker[KafkaBroker]", - broker: KafkaBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestKafkaBroker[tuple[KafkaBroker, ...]]", - *brokers: KafkaBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: KafkaBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... + @overload + def __init__( + self: "TestKafkaBroker[KafkaBroker]", + broker: KafkaBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestKafkaBroker[tuple[KafkaBroker, ...]]", + *brokers: KafkaBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: KafkaBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: + super().__init__( + *brokers, + with_real=with_real, + connect_only=connect_only, + ) @contextmanager def _patch_producer(self, broker: KafkaBroker) -> Iterator[None]: diff --git a/faststream/mqtt/testing.py b/faststream/mqtt/testing.py index e7be3af2e5a..518bf892027 100644 --- a/faststream/mqtt/testing.py +++ b/faststream/mqtt/testing.py @@ -85,35 +85,35 @@ class TestMQTTBroker(TestBroker[MQTTBroker, EnterType]): handler.mock.assert_called_once_with("hello") """ - # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker - # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the - # AST-based ``connect_only`` detection in ``TestBroker`` relies on. - if TYPE_CHECKING: - - @overload - def __init__( - self: "TestMQTTBroker[MQTTBroker]", - broker: MQTTBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestMQTTBroker[tuple[MQTTBroker, ...]]", - *brokers: MQTTBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: MQTTBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... + @overload + def __init__( + self: "TestMQTTBroker[MQTTBroker]", + broker: MQTTBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestMQTTBroker[tuple[MQTTBroker, ...]]", + *brokers: MQTTBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: MQTTBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: + super().__init__( + *brokers, + with_real=with_real, + connect_only=connect_only, + ) def create_publisher_fake_subscriber( self, diff --git a/faststream/nats/testing.py b/faststream/nats/testing.py index 37b6a45a40c..f8d8b1c5f2b 100644 --- a/faststream/nats/testing.py +++ b/faststream/nats/testing.py @@ -52,35 +52,35 @@ def change_producer( class TestNatsBroker(TestBroker[NatsBroker, EnterType]): """A class to test NATS brokers.""" - # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker - # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the - # AST-based ``connect_only`` detection in ``TestBroker`` relies on. - if TYPE_CHECKING: - - @overload - def __init__( - self: "TestNatsBroker[NatsBroker]", - broker: NatsBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestNatsBroker[tuple[NatsBroker, ...]]", - *brokers: NatsBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: NatsBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... + @overload + def __init__( + self: "TestNatsBroker[NatsBroker]", + broker: NatsBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestNatsBroker[tuple[NatsBroker, ...]]", + *brokers: NatsBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: NatsBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: + super().__init__( + *brokers, + with_real=with_real, + connect_only=connect_only, + ) def create_publisher_fake_subscriber( self, diff --git a/faststream/rabbit/testing.py b/faststream/rabbit/testing.py index d43d4cd7c79..c4da14febf4 100644 --- a/faststream/rabbit/testing.py +++ b/faststream/rabbit/testing.py @@ -46,35 +46,35 @@ class TestRabbitBroker(TestBroker[RabbitBroker, EnterType]): """A class to test RabbitMQ brokers.""" - # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker - # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the - # AST-based ``connect_only`` detection in ``TestBroker`` relies on. - if TYPE_CHECKING: - - @overload - def __init__( - self: "TestRabbitBroker[RabbitBroker]", - broker: RabbitBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestRabbitBroker[tuple[RabbitBroker, ...]]", - *brokers: RabbitBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: RabbitBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... + @overload + def __init__( + self: "TestRabbitBroker[RabbitBroker]", + broker: RabbitBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestRabbitBroker[tuple[RabbitBroker, ...]]", + *brokers: RabbitBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: RabbitBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: + super().__init__( + *brokers, + with_real=with_real, + connect_only=connect_only, + ) @contextmanager def _patch_broker(self, broker: "RabbitBroker") -> Generator[None, None, None]: diff --git a/faststream/redis/testing.py b/faststream/redis/testing.py index 9f62dd9f4c2..4c40e503b23 100644 --- a/faststream/redis/testing.py +++ b/faststream/redis/testing.py @@ -59,35 +59,35 @@ class TestRedisBroker(TestBroker[RedisBroker, EnterType]): """A class to test Redis brokers.""" - # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker - # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the - # AST-based ``connect_only`` detection in ``TestBroker`` relies on. - if TYPE_CHECKING: - - @overload - def __init__( - self: "TestRedisBroker[RedisBroker]", - broker: RedisBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestRedisBroker[tuple[RedisBroker, ...]]", - *brokers: RedisBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: RedisBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... + @overload + def __init__( + self: "TestRedisBroker[RedisBroker]", + broker: RedisBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestRedisBroker[tuple[RedisBroker, ...]]", + *brokers: RedisBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: RedisBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: + super().__init__( + *brokers, + with_real=with_real, + connect_only=connect_only, + ) @asynccontextmanager async def _create_ctx(self) -> AsyncGenerator[list[RedisBroker], None]: diff --git a/faststream/sqs/testing.py b/faststream/sqs/testing.py index 5b1a12a32c7..ddc65b3e94d 100644 --- a/faststream/sqs/testing.py +++ b/faststream/sqs/testing.py @@ -39,35 +39,35 @@ class TestSQSBroker(TestBroker[SQSBroker, EnterType]): name) without any AWS connection. """ - # ``TYPE_CHECKING``-only overloads: they bind ``EnterType`` (single broker - # vs tuple) for mypy without adding a runtime ``__init__`` frame, which the - # AST-based ``connect_only`` detection in ``TestBroker`` relies on. - if TYPE_CHECKING: - - @overload - def __init__( - self: "TestSQSBroker[SQSBroker]", - broker: SQSBroker, - /, - *, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - @overload - def __init__( - self: "TestSQSBroker[tuple[SQSBroker, ...]]", - *brokers: SQSBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... - - def __init__( - self, - *brokers: SQSBroker, - with_real: bool = False, - connect_only: bool | None = None, - ) -> None: ... + @overload + def __init__( + self: "TestSQSBroker[SQSBroker]", + broker: SQSBroker, + /, + *, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + @overload + def __init__( + self: "TestSQSBroker[tuple[SQSBroker, ...]]", + *brokers: SQSBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: ... + + def __init__( + self, + *brokers: SQSBroker, + with_real: bool = False, + connect_only: bool | None = None, + ) -> None: + super().__init__( + *brokers, + with_real=with_real, + connect_only=connect_only, + ) def create_publisher_fake_subscriber( self, From ed6b82a0543a467b55db7b21357b4a4d936d73ef Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Sat, 4 Jul 2026 21:44:01 +0300 Subject: [PATCH 18/19] refactor(sqs): restore shared test_ast.py to upstream form --- tests/utils/test_ast.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/utils/test_ast.py b/tests/utils/test_ast.py index d72912a661f..181197724b7 100644 --- a/tests/utils/test_ast.py +++ b/tests/utils/test_ast.py @@ -30,22 +30,14 @@ def __init__(self) -> None: pass -class UserWrapper: - """Mirrors a user helper that opens the ``with`` block inside ``__init__``. +class SubA(A): + """Mirrors a concrete ``TestBroker`` subclass that delegates to ``super``. - The user's ``__init__`` frame is a regular caller frame and must not be - skipped by context-name detection. + The extra ``__init__`` frame must not break context-name detection. """ def __init__(self) -> None: - with A() as a, B(): - self.contains = a.contains - - -class UserWrapperInvalid: - def __init__(self) -> None: - with B(), A() as a: - self.contains = a.contains + super().__init__() def test_base() -> None: @@ -94,12 +86,20 @@ def test_nested_invalid() -> None: assert not a.contains -def test_user_init_frame() -> None: - assert UserWrapper().contains +def test_subclass_init_chain() -> None: + with SubA() as a, B(): + assert a.contains -def test_user_init_frame_invalid() -> None: - assert not UserWrapperInvalid().contains +@pytest.mark.asyncio() +async def test_subclass_init_chain_async() -> None: + async with SubA() as a, B(): + assert a.contains + + +def test_subclass_init_chain_invalid() -> None: + with B(), SubA() as a: + assert not a.contains def test_not_broken() -> None: From 8cf9cd9b7956b9f713da78595556fe8bf6ac921a Mon Sep 17 00:00:00 2001 From: Semen Frolov Date: Mon, 6 Jul 2026 22:38:43 +0300 Subject: [PATCH 19/19] test(sqs): allowlist placeholder AWS creds for detect-secrets --- tests/brokers/sqs/basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/brokers/sqs/basic.py b/tests/brokers/sqs/basic.py index 7ec89594b54..b06606d742b 100644 --- a/tests/brokers/sqs/basic.py +++ b/tests/brokers/sqs/basic.py @@ -11,7 +11,7 @@ "endpoint_url": "http://localhost:9324", "region_name": "us-east-1", "aws_access_key_id": "test", - "aws_secret_access_key": "test", + "aws_secret_access_key": "test", # pragma: allowlist secret }