diff --git a/packages/smithy-http/.changes/next-release/smithy-http-breaking-20251017150112.json b/packages/smithy-http/.changes/next-release/smithy-http-breaking-20251017150112.json new file mode 100644 index 000000000..073a7f1a5 --- /dev/null +++ b/packages/smithy-http/.changes/next-release/smithy-http-breaking-20251017150112.json @@ -0,0 +1,4 @@ +{ + "type": "breaking", + "description": "Update `AWSCRTHTTPClient` to integrate with the new AWS CRT async interfaces. ([#573](https://github.com/smithy-lang/smithy-python/pull/573))" +} \ No newline at end of file diff --git a/packages/smithy-http/pyproject.toml b/packages/smithy-http/pyproject.toml index a9af97238..6060e0d4e 100644 --- a/packages/smithy-http/pyproject.toml +++ b/packages/smithy-http/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ [project.optional-dependencies] awscrt = [ - "awscrt>=0.23.10", + "awscrt~=0.28.2", ] aiohttp = [ "aiohttp>=3.11.12, <4.0", diff --git a/packages/smithy-http/src/smithy_http/aio/crt.py b/packages/smithy-http/src/smithy_http/aio/crt.py index 028161279..4dd3232b4 100644 --- a/packages/smithy-http/src/smithy_http/aio/crt.py +++ b/packages/smithy-http/src/smithy_http/aio/crt.py @@ -2,39 +2,38 @@ # SPDX-License-Identifier: Apache-2.0 # pyright: reportMissingTypeStubs=false,reportUnknownMemberType=false # flake8: noqa: F811 -import asyncio -from asyncio import Future as AsyncFuture -from collections import deque from collections.abc import AsyncGenerator, AsyncIterable -from concurrent.futures import Future as ConcurrentFuture from copy import deepcopy -from functools import partial -from io import BufferedIOBase, BytesIO -from typing import TYPE_CHECKING, Any, cast +from dataclasses import dataclass +from inspect import iscoroutinefunction +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - # Both of these are types that essentially are "castable to bytes/memoryview" - # Unfortunately they're not exposed anywhere so we have to import them from - # _typeshed. - from _typeshed import ReadableBuffer, WriteableBuffer - # pyright doesn't like optional imports. This is reasonable because if we use these # in type hints then they'd result in runtime errors. # TODO: add integ tests that import these without the dependendency installed from awscrt import http as crt_http from awscrt import io as crt_io + from awscrt.aio.http import ( + AIOHttpClientConnectionUnified, + AIOHttpClientStreamUnified, + ) try: from awscrt import http as crt_http from awscrt import io as crt_io + from awscrt.aio.http import ( + AIOHttpClientConnectionUnified, + AIOHttpClientStreamUnified, + ) HAS_CRT = True except ImportError: HAS_CRT = False # type: ignore from smithy_core import interfaces as core_interfaces +from smithy_core.aio import interfaces as core_aio_interfaces from smithy_core.aio.types import AsyncBytesReader -from smithy_core.aio.utils import close from smithy_core.exceptions import MissingDependencyError from .. import Field, Fields @@ -43,6 +42,9 @@ from ..interfaces import FieldPosition from . import interfaces as http_aio_interfaces +# Default buffer size for reading from streams (8 KB) +DEFAULT_READ_BUFFER_SIZE = 8192 + def _assert_crt() -> None: if not HAS_CRT: @@ -63,11 +65,17 @@ def _initialize_default_loop(self) -> "crt_io.ClientBootstrap": class AWSCRTHTTPResponse(http_aio_interfaces.HTTPResponse): - def __init__(self, *, status: int, fields: Fields, body: "CRTResponseBody") -> None: + def __init__( + self, + *, + status: int, + fields: Fields, + stream: "AIOHttpClientStreamUnified", + ) -> None: _assert_crt() self._status = status self._fields = fields - self._body = body + self._stream = stream @property def status(self) -> int: @@ -89,7 +97,7 @@ def reason(self) -> str | None: async def chunks(self) -> AsyncGenerator[bytes, None]: while True: - chunk = await self._body.next() + chunk = await self._stream.get_next_response_chunk() if chunk: yield chunk else: @@ -103,100 +111,20 @@ def __repr__(self) -> str: ) -class CRTResponseBody: - def __init__(self) -> None: - self._stream: crt_http.HttpClientStream | None = None - self._completion_future: AsyncFuture[int] | None = None - self._chunk_futures: deque[ConcurrentFuture[bytes]] = deque() - - # deque is thread safe and the crt is only going to be writing - # with one thread anyway, so we *shouldn't* need to gate this - # behind a lock. In an ideal world, the CRT would expose - # an interface that better matches python's async. - self._received_chunks: deque[bytes] = deque() - - def set_stream(self, stream: "crt_http.HttpClientStream") -> None: - if self._stream is not None: - raise SmithyHTTPError("Stream already set on AWSCRTHTTPResponse object") - self._stream = stream - concurrent_future = cast(ConcurrentFuture[int], stream.completion_future) - self._completion_future = asyncio.wrap_future(concurrent_future) - self._completion_future.add_done_callback(self._on_complete) - self._stream.activate() - - def on_body(self, chunk: bytes, **kwargs: Any) -> None: # pragma: crt-callback - # TODO: update back pressure window once CRT supports it - if self._chunk_futures: - future = self._chunk_futures.popleft() - future.set_result(chunk) - else: - self._received_chunks.append(chunk) - - async def next(self) -> bytes: - if self._completion_future is None: - raise SmithyHTTPError("Stream not set") - - # TODO: update backpressure window once CRT supports it - if self._received_chunks: - return self._received_chunks.popleft() - elif self._completion_future.done(): - return b"" - else: - future = ConcurrentFuture[bytes]() - self._chunk_futures.append(future) - return await asyncio.wrap_future(future) - - def _on_complete( - self, completion_future: AsyncFuture[int] - ) -> None: # pragma: crt-callback - for future in self._chunk_futures: - future.set_result(b"") - self._chunk_futures.clear() - - -class CRTResponseFactory: - def __init__(self, body: CRTResponseBody) -> None: - self._body = body - self._response_future = ConcurrentFuture[AWSCRTHTTPResponse]() - - def on_response( - self, status_code: int, headers: list[tuple[str, str]], **kwargs: Any - ) -> None: # pragma: crt-callback - fields = Fields() - for header_name, header_val in headers: - try: - fields[header_name].add(header_val) - except KeyError: - fields[header_name] = Field( - name=header_name, - values=[header_val], - kind=FieldPosition.HEADER, - ) - - self._response_future.set_result( - AWSCRTHTTPResponse( - status=status_code, - fields=fields, - body=self._body, - ) - ) - - async def await_response(self) -> AWSCRTHTTPResponse: - return await asyncio.wrap_future(self._response_future) - - def set_done_callback(self, stream: "crt_http.HttpClientStream") -> None: - stream.completion_future.add_done_callback(self._cancel) +ConnectionPoolKey = tuple[str, str, int | None] +ConnectionPoolDict = dict[ConnectionPoolKey, "AIOHttpClientConnectionUnified"] - def _cancel(self, completion_future: ConcurrentFuture[int | Exception]) -> None: - if not self._response_future.done(): - self._response_future.cancel() +@dataclass(kw_only=True) +class AWSCRTHTTPClientConfig(http_interfaces.HTTPClientConfiguration): + """AWS CRT HTTP client configuration. -ConnectionPoolKey = tuple[str, str, int | None] -ConnectionPoolDict = dict[ConnectionPoolKey, "crt_http.HttpClientConnection"] + :param read_buffer_size: The buffer size in bytes to use when reading from streams. + Defaults to 8192 (8 KB). + """ + read_buffer_size: int = DEFAULT_READ_BUFFER_SIZE -class AWSCRTHTTPClientConfig(http_interfaces.HTTPClientConfiguration): def __post_init__(self) -> None: _assert_crt() @@ -223,7 +151,6 @@ def __init__( self._tls_ctx = crt_io.ClientTlsContext(crt_io.TlsContextOptions()) self._socket_options = crt_io.SocketOptions() self._connections: ConnectionPoolDict = {} - self._async_reads: set[asyncio.Task[Any]] = set() async def send( self, @@ -236,45 +163,51 @@ async def send( :param request: The request including destination URI, fields, payload. :param request_config: Configuration specific to this request. """ - crt_request, crt_body = await self._marshal_request(request) + crt_request = self._marshal_request(request) connection = await self._get_connection(request.destination) - response_body = CRTResponseBody() - response_factory = CRTResponseFactory(response_body) + + # Convert body to async iterator for request_body_generator + body_generator = self._create_body_generator(request.body) + crt_stream = connection.request( crt_request, - response_factory.on_response, - response_body.on_body, + request_body_generator=body_generator, ) - response_factory.set_done_callback(crt_stream) - response_body.set_stream(crt_stream) - crt_stream.completion_future.add_done_callback( - partial(self._close_input_body, body=crt_body) - ) - - response = await response_factory.await_response() - if response.status != 200 and response.status >= 300: - await close(crt_body) - return response + return await self._await_response(crt_stream) - def _close_input_body( - self, future: ConcurrentFuture[int], *, body: "BufferableByteStream | BytesIO" - ) -> None: - if future.exception(timeout=0): - body.close() + async def _await_response( + self, stream: "AIOHttpClientStreamUnified" + ) -> AWSCRTHTTPResponse: + status_code = await stream.get_response_status_code() + headers = await stream.get_response_headers() + fields = Fields() + for header_name, header_val in headers: + try: + fields[header_name].add(header_val) + except KeyError: + fields[header_name] = Field( + name=header_name, + values=[header_val], + kind=FieldPosition.HEADER, + ) + return AWSCRTHTTPResponse( + status=status_code, + fields=fields, + stream=stream, + ) async def _create_connection( self, url: core_interfaces.URI - ) -> "crt_http.HttpClientConnection": + ) -> "AIOHttpClientConnectionUnified": """Builds and validates connection to ``url``""" - connect_future = self._build_new_connection(url) - connection = await asyncio.wrap_future(connect_future) - self._validate_connection(connection) + connection = await self._build_new_connection(url) + await self._validate_connection(connection) return connection async def _get_connection( self, url: core_interfaces.URI - ) -> "crt_http.HttpClientConnection": + ) -> "AIOHttpClientConnectionUnified": # TODO: Use CRT connection pooling instead of this basic kind connection_key = (url.scheme, url.host, url.port) connection = self._connections.get(connection_key) @@ -286,9 +219,9 @@ async def _get_connection( self._connections[connection_key] = connection return connection - def _build_new_connection( + async def _build_new_connection( self, url: core_interfaces.URI - ) -> ConcurrentFuture["crt_http.HttpClientConnection"]: + ) -> "AIOHttpClientConnectionUnified": if url.scheme == "http": port = self._HTTP_PORT tls_connection_options = None @@ -305,19 +238,17 @@ def _build_new_connection( if url.port is not None: port = url.port - connect_future = cast( - ConcurrentFuture[crt_http.HttpClientConnection], - crt_http.HttpClientConnection.new( - bootstrap=self._client_bootstrap, - host_name=url.host, - port=port, - socket_options=self._socket_options, - tls_connection_options=tls_connection_options, - ), + return await AIOHttpClientConnectionUnified.new( + bootstrap=self._client_bootstrap, + host_name=url.host, + port=port, + socket_options=self._socket_options, + tls_connection_options=tls_connection_options, ) - return connect_future - def _validate_connection(self, connection: "crt_http.HttpClientConnection") -> None: + async def _validate_connection( + self, connection: "AIOHttpClientConnectionUnified" + ) -> None: """Validates an existing connection against the client config. Checks performed: @@ -325,7 +256,7 @@ def _validate_connection(self, connection: "crt_http.HttpClientConnection") -> N """ force_http_2 = self._config.force_http_2 if force_http_2 and connection.version is not crt_http.HttpVersion.Http2: - connection.close() + await connection.close() negotiated = crt_http.HttpVersion(connection.version).name raise SmithyHTTPError(f"HTTP/2 could not be negotiated: {negotiated}") @@ -334,12 +265,12 @@ def _render_path(self, url: core_interfaces.URI) -> str: query = f"?{url.query}" if url.query is not None else "" return f"{path}{query}" - async def _marshal_request( + def _marshal_request( self, request: http_aio_interfaces.HTTPRequest - ) -> tuple["crt_http.HttpRequest", "BufferableByteStream | BytesIO"]: + ) -> "crt_http.HttpRequest": """Create :py:class:`awscrt.http.HttpRequest` from :py:class:`smithy_http.aio.HTTPRequest`""" - headers_list = [] + headers_list: list[tuple[str, str]] = [] if "host" not in request.fields: request.fields.set_field( Field(name="host", values=[request.destination.host]) @@ -358,137 +289,55 @@ async def _marshal_request( path = self._render_path(request.destination) headers = crt_http.HttpHeaders(headers_list) - body = request.body - if isinstance(body, bytes | bytearray): - # If the body is already directly in memory, wrap in a BytesIO to hand - # off to CRT. - crt_body = BytesIO(body) - else: - # If the body is async, or potentially very large, start up a task to read - # it into the intermediate object that CRT needs. By using - # asyncio.create_task we'll start the coroutine without having to - # explicitly await it. - crt_body = BufferableByteStream() - - if not isinstance(body, AsyncIterable): - body = AsyncBytesReader(body) - - # Start the read task in the background. - read_task = asyncio.create_task(self._consume_body_async(body, crt_body)) - - # Keep track of the read task so that it doesn't get garbage colllected, - # and stop tracking it once it's done. - self._async_reads.add(read_task) - read_task.add_done_callback(self._async_reads.discard) - crt_request = crt_http.HttpRequest( method=request.method, path=path, headers=headers, - body_stream=crt_body, ) - return crt_request, crt_body - - async def _consume_body_async( - self, source: AsyncIterable[bytes], dest: "BufferableByteStream" - ) -> None: - try: - async for chunk in source: - dest.write(chunk) - except Exception: - dest.close() - raise - finally: - await close(source) - dest.end_stream() + return crt_request + + async def _create_body_generator( + self, body: core_aio_interfaces.StreamingBlob + ) -> AsyncGenerator[bytes, None]: + """Convert various body types to async generator for request_body_generator.""" + if isinstance(body, bytes): + # Yield the entire body as a single chunk + yield body + elif isinstance(body, bytearray): + # Convert bytearray to bytes + yield bytes(body) + elif isinstance(body, AsyncIterable): + # Already async iterable, just yield from it. + # Check this before AsyncByteStream since AsyncBytesReader implements both. + async for chunk in body: + if isinstance(chunk, bytearray): + yield bytes(chunk) + else: + yield chunk + elif iscoroutinefunction(getattr(body, "read", None)) and isinstance( + body, # type: ignore[reportGeneralTypeIssues] + core_aio_interfaces.AsyncByteStream, # type: ignore[reportGeneralTypeIssues] + ): + # AsyncByteStream has async read method but is not iterable + while True: + chunk = await body.read(self._config.read_buffer_size) + if not chunk: + break + if isinstance(chunk, bytearray): + yield bytes(chunk) + else: + yield chunk + else: + # Assume it's a sync BytesReader, wrap it in AsyncBytesReader + async_reader = AsyncBytesReader(body) + async for chunk in async_reader: + if isinstance(chunk, bytearray): + yield bytes(chunk) + else: + yield chunk def __deepcopy__(self, memo: Any) -> "AWSCRTHTTPClient": return AWSCRTHTTPClient( eventloop=self._eventloop, client_config=deepcopy(self._config), ) - - -# This is adapted from the transcribe streaming sdk -class BufferableByteStream(BufferedIOBase): - """A non-blocking bytes buffer.""" - - def __init__(self) -> None: - # We're always manipulating the front and back of the buffer, so a deque - # will be much more efficient than a list. - self._chunks: deque[bytes] = deque() - self._closed = False - self._done = False - - def read(self, size: int | None = -1) -> bytes: - if self._closed: - return b"" - - if len(self._chunks) == 0: - if self._done: - self.close() - return b"" - else: - # When the CRT recieves this, it'll try again - raise BlockingIOError("read") - - # We could compile all the chunks here instead of just returning - # the one, BUT the CRT will keep calling read until empty bytes - # are returned. So it's actually better to just return one chunk - # since combining them would have some potentially bad memory - # usage issues. - result = self._chunks.popleft() - if size is not None and size > 0: - remainder = result[size:] - result = result[:size] - if remainder: - self._chunks.appendleft(remainder) - - if self._done and len(self._chunks) == 0: - self.close() - - return result - - def read1(self, size: int = -1) -> bytes: - return self.read(size) - - def readinto(self, buffer: "WriteableBuffer") -> int: - if not isinstance(buffer, memoryview): - buffer = memoryview(buffer).cast("B") - - data = self.read(len(buffer)) # type: ignore - n = len(data) - buffer[:n] = data - return n - - def write(self, buffer: "ReadableBuffer") -> int: - if not isinstance(buffer, bytes): - raise ValueError( - f"Unexpected value written to BufferableByteStream. " - f"Only bytes are support but {type(buffer)} was provided." - ) - - if self._closed: - raise OSError("Stream is completed and doesn't support further writes.") - - if buffer: - self._chunks.append(buffer) - return len(buffer) - - @property - def closed(self) -> bool: - return self._closed - - def close(self) -> None: - self._closed = True - self._done = True - - # Clear out the remaining chunks so that they don't sit around in memory. - self._chunks.clear() - - def end_stream(self) -> None: - """End the stream, letting any remaining chunks be read before it is closed.""" - if len(self._chunks) == 0: - self.close() - else: - self._done = True diff --git a/packages/smithy-http/tests/unit/aio/test_crt.py b/packages/smithy-http/tests/unit/aio/test_crt.py index 1ebceb3a9..5b9e851e1 100644 --- a/packages/smithy-http/tests/unit/aio/test_crt.py +++ b/packages/smithy-http/tests/unit/aio/test_crt.py @@ -1,25 +1,34 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 +# pyright: reportPrivateUsage=false import asyncio -from concurrent.futures import Future as ConcurrentFuture +from collections.abc import AsyncIterator from copy import deepcopy from io import BytesIO -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock, patch import pytest -from awscrt.http import HttpClientStream # type: ignore +from awscrt import http as crt_http # type: ignore from smithy_core import URI -from smithy_http import Fields +from smithy_core.aio.types import AsyncBytesReader +from smithy_http import Field, Fields from smithy_http.aio import HTTPRequest -from smithy_http.aio.crt import AWSCRTHTTPClient, BufferableByteStream, CRTResponseBody +from smithy_http.aio.crt import ( + AWSCRTHTTPClient, + AWSCRTHTTPClientConfig, + AWSCRTHTTPResponse, +) +from smithy_http.exceptions import SmithyHTTPError def test_deepcopy_client() -> None: + """Test that AWSCRTHTTPClient can be deep copied.""" client = AWSCRTHTTPClient() deepcopy(client) -async def test_client_marshal_request() -> None: +def test_client_marshal_request() -> None: + """Test that HTTPRequest is correctly marshaled to CRT HttpRequest.""" client = AWSCRTHTTPClient() request = HTTPRequest( method="GET", @@ -29,187 +38,336 @@ async def test_client_marshal_request() -> None: body=BytesIO(), fields=Fields(), ) - crt_request, _ = await client._marshal_request(request) # type: ignore - assert crt_request.headers.get("host") == "example.com" # type: ignore - assert crt_request.headers.get("accept") == "*/*" # type: ignore - assert crt_request.method == "GET" # type: ignore - assert crt_request.path == "/path?key1=value1&key2=value2" # type: ignore + crt_request = client._marshal_request(request) + assert crt_request.headers.get("host") == "example.com" + assert crt_request.headers.get("accept") == "*/*" + assert crt_request.method == "GET" + assert crt_request.path == "/path?key1=value1&key2=value2" -def test_stream_write() -> None: - stream = BufferableByteStream() - stream.write(b"foo") - assert stream.read() == b"foo" +async def test_body_generator_bytes() -> None: + """Test body generator with bytes input.""" + client = AWSCRTHTTPClient() + body = b"Hello, World!" + chunks: list[bytes] = [] + async for chunk in client._create_body_generator(body): + chunks.append(chunk) -def test_stream_reads_individual_chunks() -> None: - stream = BufferableByteStream() - stream.write(b"foo") - stream.write(b"bar") - assert stream.read() == b"foo" - assert stream.read() == b"bar" + assert chunks == [b"Hello, World!"] -def test_stream_empty_read() -> None: - stream = BufferableByteStream() - with pytest.raises(BlockingIOError): - stream.read() +async def test_body_generator_bytearray() -> None: + """Test body generator with bytearray input (should convert to bytes).""" + client = AWSCRTHTTPClient() + body = bytearray(b"mutable data") + chunks: list[bytes] = [] + async for chunk in client._create_body_generator(body): + chunks.append(chunk) -def test_stream_partial_chunk_read() -> None: - stream = BufferableByteStream() - stream.write(b"foobar") - assert stream.read(3) == b"foo" - assert stream.read() == b"bar" + assert chunks == [b"mutable data"] + assert all(isinstance(chunk, bytes) for chunk in chunks) -def test_stream_write_empty_bytes() -> None: - stream = BufferableByteStream() - stream.write(b"") - stream.write(b"foo") - stream.write(b"") - assert stream.read() == b"foo" +async def test_body_generator_bytesio() -> None: + """Test body generator with BytesIO (sync reader).""" + client = AWSCRTHTTPClient() + body = BytesIO(b"data from BytesIO") + chunks: list[bytes] = [] + async for chunk in client._create_body_generator(body): + chunks.append(chunk) -def test_stream_write_non_bytes() -> None: - stream = BufferableByteStream() - with pytest.raises(ValueError): - stream.write(memoryview(b"foo")) + result = b"".join(chunks) + assert result == b"data from BytesIO" -def test_closed_stream_write() -> None: - stream = BufferableByteStream() - stream.close() - with pytest.raises(IOError): - stream.write(b"foo") +async def test_body_generator_async_bytes_reader() -> None: + """Test body generator with AsyncBytesReader.""" + client = AWSCRTHTTPClient() + body = AsyncBytesReader(b"async reader data") + chunks: list[bytes] = [] + async for chunk in client._create_body_generator(body): + chunks.append(chunk) -def test_closed_stream_read() -> None: - stream = BufferableByteStream() - stream.write(b"foo") - stream.close() - assert stream.read() == b"" + result = b"".join(chunks) + assert result == b"async reader data" -def test_done_stream_read() -> None: - stream = BufferableByteStream() - stream.write(b"foo") - stream.end_stream() - assert stream.read() == b"foo" - assert stream.read() == b"" +async def test_body_generator_async_iterable() -> None: + """Test body generator with custom AsyncIterable.""" + async def custom_generator() -> AsyncIterator[bytes]: + yield b"chunk1" + yield b"chunk2" + yield b"chunk3" -def test_end_empty_stream() -> None: - stream = BufferableByteStream() - stream.end_stream() - assert stream.read() == b"" + client = AWSCRTHTTPClient() + body = custom_generator() + chunks: list[bytes] = [] + async for chunk in client._create_body_generator(body): + chunks.append(chunk) -def test_stream_read1() -> None: - stream = BufferableByteStream() - stream.write(b"foo") - stream.write(b"bar") - assert stream.read1() == b"foo" - assert stream.read1() == b"bar" - with pytest.raises(BlockingIOError): - stream.read() + assert chunks == [b"chunk1", b"chunk2", b"chunk3"] -def test_stream_readinto_memoryview() -> None: - buffer = memoryview(bytearray(b" ")) - stream = BufferableByteStream() - stream.write(b"foobar") - stream.readinto(buffer) - assert bytes(buffer) == b"foo" +async def test_body_generator_async_iterable_with_bytearray() -> None: + """Test that AsyncIterable yielding bytearray converts to bytes.""" + async def generator_with_bytearray() -> AsyncIterator[bytes | bytearray]: + yield b"bytes chunk" + yield bytearray(b"bytearray chunk") + yield b"more bytes" -def test_stream_readinto_bytearray() -> None: - buffer = bytearray(b" ") - stream = BufferableByteStream() - stream.write(b"foobar") - stream.readinto(buffer) - assert bytes(buffer) == b"foo" + client = AWSCRTHTTPClient() + body = generator_with_bytearray() + chunks: list[bytes] = [] + async for chunk in client._create_body_generator(body): # type: ignore + chunks.append(chunk) -def test_end_stream() -> None: - stream = BufferableByteStream() - stream.write(b"foo") - stream.end_stream() + assert chunks == [b"bytes chunk", b"bytearray chunk", b"more bytes"] + assert all(isinstance(chunk, bytes) for chunk in chunks) - assert not stream.closed - assert stream.read() == b"foo" - assert stream.closed +async def test_body_generator_async_byte_stream() -> None: + """Test body generator with AsyncByteStream (object with async read).""" -async def test_response_body_completed_stream() -> None: - completion_future = ConcurrentFuture[int]() - mock_stream = Mock(spec=HttpClientStream) - mock_stream.completion_future = completion_future + class CustomAsyncStream: + def __init__(self, data: bytes): + self._data = BytesIO(data) - response_body = CRTResponseBody() - response_body.set_stream(mock_stream) - completion_future.set_result(200) + async def read(self, size: int = -1) -> bytes: + # Simulate async read + await asyncio.sleep(0) + return self._data.read(size) - assert await response_body.next() == b"" + client = AWSCRTHTTPClient() + body = CustomAsyncStream(b"x" * 100000) # 100KB of data + chunks: list[bytes] = [] + async for chunk in client._create_body_generator(body): + chunks.append(chunk) -async def test_response_body_empty_stream() -> None: - completion_future = ConcurrentFuture[int]() - mock_stream = Mock(spec=HttpClientStream) - mock_stream.completion_future = completion_future + # Should read in 64KB chunks + result = b"".join(chunks) + assert len(result) == 100000 + assert result == b"x" * 100000 - response_body = CRTResponseBody() - response_body.set_stream(mock_stream) - read_task = asyncio.create_task(response_body.next()) +async def test_body_generator_empty_bytes() -> None: + """Test body generator with empty bytes.""" + client = AWSCRTHTTPClient() + body = b"" - # Sleep briefly so the read task gets priority. It should - # add a chunk future and then await it. - await asyncio.sleep(0.01) + chunks: list[bytes] = [] + async for chunk in client._create_body_generator(body): + chunks.append(chunk) - assert len(response_body._chunk_futures) == 1 # type: ignore - response_body.on_body(b"foo") - assert await read_task == b"foo" + assert chunks == [b""] -async def test_response_body_stream_completion_clears_buffer() -> None: - completion_future = ConcurrentFuture[int]() - mock_stream = Mock(spec=HttpClientStream) - mock_stream.completion_future = completion_future +async def test_build_connection_http() -> None: + """Test building HTTP connection.""" + client = AWSCRTHTTPClient() + url = URI(scheme="http", host="example.com", port=8080) - response_body = CRTResponseBody() - response_body.set_stream(mock_stream) + with patch("smithy_http.aio.crt.AIOHttpClientConnectionUnified.new") as mock_new: + mock_connection = AsyncMock() + mock_connection.version = crt_http.HttpVersion.Http1_1 + mock_connection.is_open = Mock(return_value=True) + mock_new.return_value = mock_connection + + connection = await client._build_new_connection(url) + + assert connection is mock_connection + mock_new.assert_called_once() + call_kwargs = mock_new.call_args[1] + assert call_kwargs["host_name"] == "example.com" + assert call_kwargs["port"] == 8080 + assert call_kwargs["tls_connection_options"] is None + + +async def test_build_connection_https() -> None: + """Test building HTTPS connection with TLS.""" + client = AWSCRTHTTPClient() + url = URI(scheme="https", host="secure.example.com") + + with patch("smithy_http.aio.crt.AIOHttpClientConnectionUnified.new") as mock_new: + mock_connection = AsyncMock() + mock_connection.version = crt_http.HttpVersion.Http2 + mock_connection.is_open = Mock(return_value=True) + mock_new.return_value = mock_connection + + connection = await client._build_new_connection(url) + + assert connection is mock_connection + mock_new.assert_called_once() + call_kwargs = mock_new.call_args[1] + assert call_kwargs["host_name"] == "secure.example.com" + assert call_kwargs["port"] == 443 + assert call_kwargs["tls_connection_options"] is not None + + +async def test_build_connection_unsupported_scheme() -> None: + """Test that unsupported URL schemes raise error.""" + client = AWSCRTHTTPClient() + url = URI(scheme="ftp", host="example.com") + + with pytest.raises(SmithyHTTPError, match="does not support URL scheme ftp"): + await client._build_new_connection(url) + + +async def test_validate_connection_http2_required() -> None: + """Test connection validation when force_http_2 is enabled.""" + config = AWSCRTHTTPClientConfig(force_http_2=True) + client = AWSCRTHTTPClient(client_config=config) + + # Mock HTTP/1.1 connection + mock_connection = AsyncMock() + mock_connection.version = crt_http.HttpVersion.Http1_1 + mock_connection.close = AsyncMock() + + with pytest.raises(SmithyHTTPError, match="HTTP/2 could not be negotiated"): + await client._validate_connection(mock_connection) + + mock_connection.close.assert_called_once() + + +async def test_validate_connection_http2_success() -> None: + """Test connection validation succeeds with HTTP/2.""" + config = AWSCRTHTTPClientConfig(force_http_2=True) + client = AWSCRTHTTPClient(client_config=config) + + # Mock HTTP/2 connection + mock_connection = AsyncMock() + mock_connection.version = crt_http.HttpVersion.Http2 + + # Should not raise + await client._validate_connection(mock_connection) + + +async def test_connection_pooling() -> None: + """Test that connections are pooled and reused.""" + client = AWSCRTHTTPClient() + url = URI(scheme="https", host="example.com") + + # Mock connection + mock_connection = AsyncMock() + mock_connection.version = crt_http.HttpVersion.Http2 + # is_open() should be a regular method, not async + mock_connection.is_open = Mock(return_value=True) + + with patch("smithy_http.aio.crt.AIOHttpClientConnectionUnified.new") as mock_new: + mock_new.return_value = mock_connection + + # First call should create new connection + conn1 = await client._get_connection(url) + assert mock_new.call_count == 1 + + # Second call should reuse connection + conn2 = await client._get_connection(url) + assert mock_new.call_count == 1 # Not called again + assert conn1 is conn2 + + +async def test_connection_pooling_different_hosts() -> None: + """Test that different hosts get different connections.""" + client = AWSCRTHTTPClient() + url1 = URI(scheme="https", host="example1.com") + url2 = URI(scheme="https", host="example2.com") + + # Create two distinct mock connections + mock_conn1 = AsyncMock() + mock_conn1.version = crt_http.HttpVersion.Http2 + mock_conn1.is_open = Mock(return_value=True) + + mock_conn2 = AsyncMock() + mock_conn2.version = crt_http.HttpVersion.Http2 + mock_conn2.is_open = Mock(return_value=True) + + with patch("smithy_http.aio.crt.AIOHttpClientConnectionUnified.new") as mock_new: + mock_new.side_effect = [mock_conn1, mock_conn2] + + conn1 = await client._get_connection(url1) + conn2 = await client._get_connection(url2) + + assert mock_new.call_count == 2 + assert conn1 is mock_conn1 + assert conn2 is mock_conn2 + assert conn1 is not conn2 + + +async def test_connection_pooling_closed_connection() -> None: + """Test that closed connections are replaced.""" + client = AWSCRTHTTPClient() + url = URI(scheme="https", host="example.com") + + mock_connection1 = AsyncMock() + mock_connection1.version = crt_http.HttpVersion.Http2 + mock_connection1.is_open = Mock(return_value=False) # Closed + + mock_connection2 = AsyncMock() + mock_connection2.version = crt_http.HttpVersion.Http2 + mock_connection2.is_open = Mock(return_value=True) + + with patch("smithy_http.aio.crt.AIOHttpClientConnectionUnified.new") as mock_new: + mock_new.side_effect = [mock_connection1, mock_connection2] + + # First call + conn1 = await client._get_connection(url) + assert conn1 is mock_connection1 + + # Connection is now closed, should create new one + conn2 = await client._get_connection(url) + assert conn2 is mock_connection2 + assert mock_new.call_count == 2 + + +async def test_response_chunks() -> None: + """Test reading response body chunks.""" + mock_stream = AsyncMock() + mock_stream.get_next_response_chunk.side_effect = [ + b"chunk1", + b"chunk2", + b"chunk3", + b"", # End of stream + ] + + response = AWSCRTHTTPResponse(status=200, fields=Fields(), stream=mock_stream) + + chunks: list[bytes] = [] + async for chunk in response.chunks(): + chunks.append(chunk) + + assert chunks == [b"chunk1", b"chunk2", b"chunk3"] - read_tasks = ( - asyncio.create_task(response_body.next()), - asyncio.create_task(response_body.next()), - asyncio.create_task(response_body.next()), - asyncio.create_task(response_body.next()), - ) - # Sleep briefly so the read tasks gets priority. It should - # add a chunk future and then await it. - await asyncio.sleep(0.01) +async def test_response_body_property() -> None: + """Test that body property returns chunks.""" + mock_stream = AsyncMock() + mock_stream.get_next_response_chunk.side_effect = [b"data", b""] - assert len(response_body._chunk_futures) == 4 # type: ignore - completion_future.set_result(200) - await asyncio.sleep(0.01) + response = AWSCRTHTTPResponse(status=200, fields=Fields(), stream=mock_stream) - # Tasks should have been drained - assert len(response_body._chunk_futures) == 0 # type: ignore + chunks: list[bytes] = [] + async for chunk in response.body: + chunks.append(chunk) - # Tasks should still be awaited, and should all return empty - results = asyncio.gather(*read_tasks) - assert results.result() == [b"", b"", b"", b""] + assert chunks == [b"data"] -async def test_response_body_non_empty_stream() -> None: - completion_future = ConcurrentFuture[int]() - mock_stream = Mock(spec=HttpClientStream) - mock_stream.completion_future = completion_future +def test_response_properties() -> None: + """Test response property accessors.""" + fields = Fields() + fields.set_field(Field(name="content-type", values=["application/json"])) - response_body = CRTResponseBody() - response_body.set_stream(mock_stream) - response_body.on_body(b"foo") + mock_stream = Mock() + response = AWSCRTHTTPResponse(status=404, fields=fields, stream=mock_stream) - assert await response_body.next() == b"foo" + assert response.status == 404 + assert response.fields == fields + assert response.reason is None diff --git a/uv.lock b/uv.lock index b461787e0..3bf8fb831 100644 --- a/uv.lock +++ b/uv.lock @@ -135,24 +135,24 @@ source = { editable = "packages/aws-sdk-signers" } [[package]] name = "awscrt" -version = "0.26.1" +version = "0.28.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/42/db/72989a426cdf2b9f38454b1cdba246b2d2e95a77397ad3df18d1d9d4f5b3/awscrt-0.26.1.tar.gz", hash = "sha256:a8d63a7dcc6484c5c1675b31a8d1b6726c3dc85b13796fb143dfb0072260935e", size = 77265756, upload-time = "2025-04-09T20:51:33.943Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/1b/a885a699217967c3ff0e1c49ac5b1e2a050d1a8b87d1e85e958a56e3d3f5/awscrt-0.28.2.tar.gz", hash = "sha256:9715a888f2042e710dc8aeb355963a29b77e7a4cc25a14659cebd21a5fa476c1", size = 37894849, upload-time = "2025-10-14T19:06:16.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/0b/0894bebe4454959ce70e6a22886af6d9ec8a7de3c51321630b288a4cbd02/awscrt-0.26.1-cp311-abi3-macosx_10_15_universal2.whl", hash = "sha256:02ec2045ae8bb2ed3dcf5a820b14903c7a34f1e9278d810a6a210f761fe69cd9", size = 3215994, upload-time = "2025-04-09T20:50:31.564Z" }, - { url = "https://files.pythonhosted.org/packages/64/dd/5a8cd31da190aeca9862f51febf0b98932e1101ce7957698e5708c5cdaa4/awscrt-0.26.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15e0d0a3ce2ba5de0830e790c1270944c372f5f3e70facfebb14d3968a467e69", size = 8510940, upload-time = "2025-04-09T20:50:33.528Z" }, - { url = "https://files.pythonhosted.org/packages/66/89/a63cfe4cb03d55b75674d97280c2e94317d8dbde7418dfe0788e436d36a9/awscrt-0.26.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a24016bd4b37cf493ac1099330c460f6366e28c364e2e5e9ff8234065fde504", size = 8787937, upload-time = "2025-04-09T20:50:35.568Z" }, - { url = "https://files.pythonhosted.org/packages/69/08/8b838b0e8c3fb8148193080938f0375bde09ca67be871a3f678f15dcf7de/awscrt-0.26.1-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a928c3bfa8a886d65a2bd2bb05f0d98ff6e7d9af5058b5d0cfc8f0a7081bb8e2", size = 8589500, upload-time = "2025-04-09T20:50:38Z" }, - { url = "https://files.pythonhosted.org/packages/3a/0b/90aa0c895d47601ddfdabfa78b9d43e182ddc135b537009097b1beb2b68a/awscrt-0.26.1-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:d6cee25700381929220b8ddb1757a08017f7538fd06a4720cfc92ac22da41c20", size = 8977442, upload-time = "2025-04-09T20:50:39.739Z" }, - { url = "https://files.pythonhosted.org/packages/25/53/8db31969f75f8e1cec1abf362721e6e044848cce48a80122c30a9b7b951c/awscrt-0.26.1-cp311-abi3-win32.whl", hash = "sha256:48e86ef8083425eab55b76cd9056dc0d7816c9939008d44f2aeffa0dfe707103", size = 3678206, upload-time = "2025-04-09T20:50:41.575Z" }, - { url = "https://files.pythonhosted.org/packages/86/9a/7ea111c776df38abb6a0e9b485416cab0c9ec4abb089f87c7d8366470c74/awscrt-0.26.1-cp311-abi3-win_amd64.whl", hash = "sha256:0c456b55c61bb6ba51e9cef49402f4fdf2a1b9ccf41addd00c6ec69d7b8f501f", size = 3809539, upload-time = "2025-04-09T20:50:42.908Z" }, - { url = "https://files.pythonhosted.org/packages/f4/79/b24e73ff6ad26e5ef2265ebbba514fe4612bbfb443a7a55f1d61f0e14f35/awscrt-0.26.1-cp313-abi3-macosx_10_15_universal2.whl", hash = "sha256:3f05e1fbbd834f44e5654f58a411f138fcedf3671edcfcd64795ed2a3ee74432", size = 3215180, upload-time = "2025-04-09T20:50:44.212Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/bdff8699e622d2b0dfa2360bc125629ddeadf8365df8e30e2576a826465e/awscrt-0.26.1-cp313-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d11db081a059ac198a2628a49e6acc3f18c75bf08a83090308241ba17e7b290b", size = 8505767, upload-time = "2025-04-09T20:50:45.54Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a7/dfb26c394ec4ddeeb0cd0c543f05197ec0d948a3ddaafba36fce6581a206/awscrt-0.26.1-cp313-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0b8fb0ac48a0178ab7d1bc8ea33862103bfae1f740afcb7c24e9a88116bec7", size = 8784776, upload-time = "2025-04-09T20:50:47.974Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1b/933866c3a87cf287c5c002f1682c69ccc1f448bf557172ebb955f5c3d275/awscrt-0.26.1-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:76ec9f19d43766f64a2210807583cf9f48a72ca8e215199ca7dcc1cf17cf77fe", size = 8584536, upload-time = "2025-04-09T20:50:51.125Z" }, - { url = "https://files.pythonhosted.org/packages/85/aa/5bdc01e7b8c1444ba3ec17b4d254da7ce46622f7d704f34f5bb8ec3a41d2/awscrt-0.26.1-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:507fa4106d1bbf6b5fbb1c4d287eb832a0459a361eb7f5aa5e5272f15f3e9077", size = 8974509, upload-time = "2025-04-09T20:50:52.868Z" }, - { url = "https://files.pythonhosted.org/packages/d8/2d/c3146eec592a192b331c5796aee229d98788ce4a89c0827194e97ae2fac0/awscrt-0.26.1-cp313-abi3-win32.whl", hash = "sha256:1ebf5078d1281cfe51cd9ed7f324cb25d58404540c1be0d8794e48a033b01dca", size = 3675889, upload-time = "2025-04-09T20:50:55.622Z" }, - { url = "https://files.pythonhosted.org/packages/83/f8/7a2de6f30f00cacfae286c98fb0c96ff456e7d1fdfe7d368073c8cd53478/awscrt-0.26.1-cp313-abi3-win_amd64.whl", hash = "sha256:6fbffffee0333f8ae54ed12958c492b37e1803e6c27e6a2fff8f56851f748c45", size = 3804270, upload-time = "2025-04-09T20:50:56.915Z" }, + { url = "https://files.pythonhosted.org/packages/ed/79/94e9f0ee7c60ec6233c7ad6293589c56d5145172e49eb5328eda37d3fdd1/awscrt-0.28.2-cp311-abi3-macosx_10_15_universal2.whl", hash = "sha256:025eab99b58586d8c95f8fafe1f4695ad477eda20d1207240ee4f8ee79742059", size = 3381061, upload-time = "2025-10-14T19:05:27.187Z" }, + { url = "https://files.pythonhosted.org/packages/2d/b8/0da80dd58682ddf3ec204e877d5891198654647c085e65b6b8eacd214edb/awscrt-0.28.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5c18d035d6cd92228e1db2f043517c1bcf9e0f6430c0af60cc34257dcca092c", size = 3788011, upload-time = "2025-10-14T19:05:28.768Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d2/f51cf4364364399fe90d557e2fed14c1f114720191a5825898b1242bd607/awscrt-0.28.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c75f077e90d0220a49b75a9bca914e5aa1a3c8f28af6bce4d0332be0b98dd3cb", size = 4055226, upload-time = "2025-10-14T19:05:30.054Z" }, + { url = "https://files.pythonhosted.org/packages/41/47/0fde8738a8c76de278ce431d8468ef18aeaca424329decca9ad5092df812/awscrt-0.28.2-cp311-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1432c5c59a7e36b33eb2746cfbf30058f19ed43f2c117863897681f70bc246ba", size = 3692839, upload-time = "2025-10-14T19:05:31.471Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/cb3762f6b47fe503eea7f337eca7cfd044ab28bcc2452fbf298c6492ec8b/awscrt-0.28.2-cp311-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f96703c30b22ba1e43e1bb2fe996ac7af513bea411c54dbf09a3a1af329b9a76", size = 3918023, upload-time = "2025-10-14T19:05:33.162Z" }, + { url = "https://files.pythonhosted.org/packages/95/0a/0b609acd45dbb83c04c7ecb8c7c789f5c15bbdd422129360bde093bc4a99/awscrt-0.28.2-cp311-abi3-win32.whl", hash = "sha256:3e94f63497b454d30892d7a7ce917a451c6f33590964d3a475d93f93b20083b6", size = 3917048, upload-time = "2025-10-14T19:05:34.745Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/bf33abd6d09c8572f8e09488db2b0a60124767d7f5d6d9a33cf8b051b7af/awscrt-0.28.2-cp311-abi3-win_amd64.whl", hash = "sha256:3e094772b1f6fd0f8c5f7cf37655d0984739f99493f66f534979a2a7bb7fc9f6", size = 4052877, upload-time = "2025-10-14T19:05:36.01Z" }, + { url = "https://files.pythonhosted.org/packages/10/71/4be198e472d95702434cee1f9dd889c56e22bea8554b466fad754148fd24/awscrt-0.28.2-cp313-abi3-macosx_10_15_universal2.whl", hash = "sha256:5fda9e7d0eb800491fadebe2b6c2560ac2f5742b60f4106440dca4b49da7fb03", size = 3379585, upload-time = "2025-10-14T19:05:37.225Z" }, + { url = "https://files.pythonhosted.org/packages/43/09/77084249d07dca71352341ad3fbcfa75deaccf25bd65f9fdbb36ce1f978b/awscrt-0.28.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:994a795bdc83344922a15891abb30155ec292093e856eef3929dd63dd6cadaca", size = 3779843, upload-time = "2025-10-14T19:05:38.774Z" }, + { url = "https://files.pythonhosted.org/packages/a6/bb/fcee9365e58e5860582398317571a9a5517da258cd81c3d987b9882f61d4/awscrt-0.28.2-cp313-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28537c4517168927ef74aa007a2e0c9f436921227934d82da31e9a1cec7e0c4a", size = 4049154, upload-time = "2025-10-14T19:05:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/ac92b2707dbe05e56d0dd5af73cb4e07a3da4aee66936071123966523759/awscrt-0.28.2-cp313-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:b9fc6be63832da3ff244d56c7d9a43326d89d79e68162419c35f33e6ad033be0", size = 3683672, upload-time = "2025-10-14T19:05:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d0/15308ec37e762691f5d1871b0f1a6e462da8e421c6c38d6724e3cf0994b2/awscrt-0.28.2-cp313-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:efb57103a368de1d33148cb70a382c4f82ac376c744de9484e0f621cef8313f3", size = 3912823, upload-time = "2025-10-14T19:05:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/7693b1d72069908b7a3ee30e4ef2b5fc8f54948a96397729277cb0b0c7b4/awscrt-0.28.2-cp313-abi3-win32.whl", hash = "sha256:594dc61f4f0c1c9fb7292364d25c21810b3608cd67c0de78a032ad48f7bfd88c", size = 3911514, upload-time = "2025-10-14T19:05:45.019Z" }, + { url = "https://files.pythonhosted.org/packages/93/d6/5d8545c967690f03d55d44ed56ceff26d88363cd7d0435fd80a1c843ac2a/awscrt-0.28.2-cp313-abi3-win_amd64.whl", hash = "sha256:a17f0ab9dc5e5301da0fb00ccc4511a136d13abbd4a9564827547333fcd7ba16", size = 4047912, upload-time = "2025-10-14T19:05:46.302Z" }, ] [[package]] @@ -508,15 +508,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.400" +version = "1.1.406" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/cb/c306618a02d0ee8aed5fb8d0fe0ecfed0dbf075f71468f03a30b5f4e1fe0/pyright-1.1.400.tar.gz", hash = "sha256:b8a3ba40481aa47ba08ffb3228e821d22f7d391f83609211335858bf05686bdb", size = 3846546, upload-time = "2025-04-24T12:55:18.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151, upload-time = "2025-10-02T01:04:45.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/a5/5d285e4932cf149c90e3c425610c5efaea005475d5f96f1bfdb452956c62/pyright-1.1.400-py3-none-any.whl", hash = "sha256:c80d04f98b5a4358ad3a35e241dbf2a408eee33a40779df365644f8054d2517e", size = 5563460, upload-time = "2025-04-24T12:55:17.002Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982, upload-time = "2025-10-02T01:04:43.137Z" }, ] [[package]] @@ -675,7 +675,7 @@ awscrt = [ [package.metadata] requires-dist = [ { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.11.12,<4.0" }, - { name = "awscrt", marker = "extra == 'awscrt'", specifier = ">=0.23.10" }, + { name = "awscrt", marker = "extra == 'awscrt'", specifier = "~=0.28.2" }, { name = "smithy-core", editable = "packages/smithy-core" }, { name = "yarl", marker = "extra == 'aiohttp'" }, ]