smithy-http 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. smithy_http-0.2.0/.changes/0.2.0.json +8 -0
  2. smithy_http-0.2.0/CHANGELOG.md +20 -0
  3. {smithy_http-0.1.0 → smithy_http-0.2.0}/PKG-INFO +2 -2
  4. {smithy_http-0.1.0 → smithy_http-0.2.0}/pyproject.toml +1 -1
  5. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/__init__.py +1 -1
  6. smithy_http-0.2.0/src/smithy_http/aio/crt.py +343 -0
  7. smithy_http-0.1.0/CHANGELOG.md +0 -15
  8. smithy_http-0.1.0/src/smithy_http/aio/crt.py +0 -494
  9. {smithy_http-0.1.0 → smithy_http-0.2.0}/.changes/0.0.1.json +0 -0
  10. {smithy_http-0.1.0 → smithy_http-0.2.0}/.changes/0.1.0.json +0 -0
  11. {smithy_http-0.1.0 → smithy_http-0.2.0}/.gitignore +0 -0
  12. {smithy_http-0.1.0 → smithy_http-0.2.0}/NOTICE +0 -0
  13. {smithy_http-0.1.0 → smithy_http-0.2.0}/README.md +0 -0
  14. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/aio/__init__.py +0 -0
  15. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/aio/aiohttp.py +0 -0
  16. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/aio/auth/__init__.py +0 -0
  17. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/aio/auth/apikey.py +0 -0
  18. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/aio/identity/__init__.py +0 -0
  19. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/aio/identity/apikey.py +0 -0
  20. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/aio/interfaces/__init__.py +0 -0
  21. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/aio/protocols.py +0 -0
  22. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/aio/restjson.py +0 -0
  23. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/bindings.py +0 -0
  24. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/deserializers.py +0 -0
  25. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/endpoints.py +0 -0
  26. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/exceptions.py +0 -0
  27. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/interceptors/__init__.py +0 -0
  28. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/interceptors/user_agent.py +0 -0
  29. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/interfaces/__init__.py +0 -0
  30. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/plugins/__init__.py +0 -0
  31. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/py.typed +0 -0
  32. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/restjson.py +0 -0
  33. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/serializers.py +0 -0
  34. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/user_agent.py +0 -0
  35. {smithy_http-0.1.0 → smithy_http-0.2.0}/src/smithy_http/utils.py +0 -0
@@ -0,0 +1,8 @@
1
+ {
2
+ "changes": [
3
+ {
4
+ "type": "breaking",
5
+ "description": "Update `AWSCRTHTTPClient` to integrate with the new AWS CRT async interfaces. ([#573](https://github.com/smithy-lang/smithy-python/pull/573)). The `AWSCRTHTTPResponse` constructor now accepts a `stream` argument of type `awscrt.aio.http.AIOHttpClientStreamUnified`. Additionally, the following classes were removed: `CRTResponseBody`, `CRTResponseFactory`, and `BufferableByteStream`."
6
+ }
7
+ ]
8
+ }
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## v0.2.0
4
+
5
+ ### Breaking Changes
6
+ * Update `AWSCRTHTTPClient` to integrate with the new AWS CRT async interfaces. ([#573](https://github.com/smithy-lang/smithy-python/pull/573)). The `AWSCRTHTTPResponse` constructor now accepts a `stream` argument of type `awscrt.aio.http.AIOHttpClientStreamUnified`. Additionally, the following classes were removed: `CRTResponseBody`, `CRTResponseFactory`, and `BufferableByteStream`.
7
+
8
+ ## v0.1.0
9
+
10
+ ### Breaking Changes
11
+ * Removed identity and auth interfaces in favor of the transport-agnostic interfaces introduced in `smithy-core`.
12
+
13
+ ### Features
14
+ * Introduced schema-based serializers and deserializers for HTTP binding protocols.
15
+
16
+ ## v0.0.1
17
+
18
+ ### Features
19
+ * Added support for aiohttp and AWSCRT http clients.
20
+ * Added basic HTTP primitives and interfaces for Smithy clients.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smithy-http
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: HTTP components for Smithy tooling.
5
5
  Project-URL: Changelog, https://github.com/smithy-lang/smithy-python/blob/develop/packages/smithy-http/CHANGELOG.md
6
6
  Project-URL: Code, https://github.com/smithy-lang/smithy-python/blob/develop/packages/smithy-http/
@@ -29,7 +29,7 @@ Provides-Extra: aiohttp
29
29
  Requires-Dist: aiohttp<4.0,>=3.11.12; extra == 'aiohttp'
30
30
  Requires-Dist: yarl; extra == 'aiohttp'
31
31
  Provides-Extra: awscrt
32
- Requires-Dist: awscrt>=0.23.10; extra == 'awscrt'
32
+ Requires-Dist: awscrt~=0.28.2; extra == 'awscrt'
33
33
  Description-Content-Type: text/markdown
34
34
 
35
35
  # smithy-http
@@ -36,7 +36,7 @@ dependencies = [
36
36
 
37
37
  [project.optional-dependencies]
38
38
  awscrt = [
39
- "awscrt>=0.23.10",
39
+ "awscrt~=0.28.2",
40
40
  ]
41
41
  aiohttp = [
42
42
  "aiohttp>=3.11.12, <4.0",
@@ -6,7 +6,7 @@ from collections.abc import Iterable, Iterator
6
6
  from . import interfaces
7
7
  from .interfaces import FieldPosition
8
8
 
9
- __version__ = "0.1.0"
9
+ __version__ = "0.2.0"
10
10
 
11
11
 
12
12
  class Field(interfaces.Field):
@@ -0,0 +1,343 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # pyright: reportMissingTypeStubs=false,reportUnknownMemberType=false
4
+ # flake8: noqa: F811
5
+ from collections.abc import AsyncGenerator, AsyncIterable
6
+ from copy import deepcopy
7
+ from dataclasses import dataclass
8
+ from inspect import iscoroutinefunction
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ # pyright doesn't like optional imports. This is reasonable because if we use these
13
+ # in type hints then they'd result in runtime errors.
14
+ # TODO: add integ tests that import these without the dependendency installed
15
+ from awscrt import http as crt_http
16
+ from awscrt import io as crt_io
17
+ from awscrt.aio.http import (
18
+ AIOHttpClientConnectionUnified,
19
+ AIOHttpClientStreamUnified,
20
+ )
21
+
22
+ try:
23
+ from awscrt import http as crt_http
24
+ from awscrt import io as crt_io
25
+ from awscrt.aio.http import (
26
+ AIOHttpClientConnectionUnified,
27
+ AIOHttpClientStreamUnified,
28
+ )
29
+
30
+ HAS_CRT = True
31
+ except ImportError:
32
+ HAS_CRT = False # type: ignore
33
+
34
+ from smithy_core import interfaces as core_interfaces
35
+ from smithy_core.aio import interfaces as core_aio_interfaces
36
+ from smithy_core.aio.types import AsyncBytesReader
37
+ from smithy_core.exceptions import MissingDependencyError
38
+
39
+ from .. import Field, Fields
40
+ from .. import interfaces as http_interfaces
41
+ from ..exceptions import SmithyHTTPError
42
+ from ..interfaces import FieldPosition
43
+ from . import interfaces as http_aio_interfaces
44
+
45
+ # Default buffer size for reading from streams (8 KB)
46
+ DEFAULT_READ_BUFFER_SIZE = 8192
47
+
48
+
49
+ def _assert_crt() -> None:
50
+ if not HAS_CRT:
51
+ raise MissingDependencyError(
52
+ "Attempted to use awscrt component, but awscrt is not installed."
53
+ )
54
+
55
+
56
+ class _AWSCRTEventLoop:
57
+ def __init__(self) -> None:
58
+ _assert_crt()
59
+ self.bootstrap = self._initialize_default_loop()
60
+
61
+ def _initialize_default_loop(self) -> "crt_io.ClientBootstrap":
62
+ event_loop_group = crt_io.EventLoopGroup(1)
63
+ host_resolver = crt_io.DefaultHostResolver(event_loop_group)
64
+ return crt_io.ClientBootstrap(event_loop_group, host_resolver)
65
+
66
+
67
+ class AWSCRTHTTPResponse(http_aio_interfaces.HTTPResponse):
68
+ def __init__(
69
+ self,
70
+ *,
71
+ status: int,
72
+ fields: Fields,
73
+ stream: "AIOHttpClientStreamUnified",
74
+ ) -> None:
75
+ _assert_crt()
76
+ self._status = status
77
+ self._fields = fields
78
+ self._stream = stream
79
+
80
+ @property
81
+ def status(self) -> int:
82
+ return self._status
83
+
84
+ @property
85
+ def fields(self) -> Fields:
86
+ return self._fields
87
+
88
+ @property
89
+ def body(self) -> AsyncIterable[bytes]:
90
+ return self.chunks()
91
+
92
+ @property
93
+ def reason(self) -> str | None:
94
+ """Optional string provided by the server explaining the status."""
95
+ # TODO: See how CRT exposes reason.
96
+ return None
97
+
98
+ async def chunks(self) -> AsyncGenerator[bytes, None]:
99
+ while True:
100
+ chunk = await self._stream.get_next_response_chunk()
101
+ if chunk:
102
+ yield chunk
103
+ else:
104
+ break
105
+
106
+ def __repr__(self) -> str:
107
+ return (
108
+ f"AWSCRTHTTPResponse("
109
+ f"status={self.status}, "
110
+ f"fields={self.fields!r}, body=...)"
111
+ )
112
+
113
+
114
+ ConnectionPoolKey = tuple[str, str, int | None]
115
+ ConnectionPoolDict = dict[ConnectionPoolKey, "AIOHttpClientConnectionUnified"]
116
+
117
+
118
+ @dataclass(kw_only=True)
119
+ class AWSCRTHTTPClientConfig(http_interfaces.HTTPClientConfiguration):
120
+ """AWS CRT HTTP client configuration.
121
+
122
+ :param read_buffer_size: The buffer size in bytes to use when reading from streams.
123
+ Defaults to 8192 (8 KB).
124
+ """
125
+
126
+ read_buffer_size: int = DEFAULT_READ_BUFFER_SIZE
127
+
128
+ def __post_init__(self) -> None:
129
+ _assert_crt()
130
+
131
+
132
+ class AWSCRTHTTPClient(http_aio_interfaces.HTTPClient):
133
+ _HTTP_PORT = 80
134
+ _HTTPS_PORT = 443
135
+
136
+ def __init__(
137
+ self,
138
+ eventloop: _AWSCRTEventLoop | None = None,
139
+ client_config: AWSCRTHTTPClientConfig | None = None,
140
+ ) -> None:
141
+ """
142
+ :param client_config: Configuration that applies to all requests made with this
143
+ client.
144
+ """
145
+ _assert_crt()
146
+ self._config = client_config or AWSCRTHTTPClientConfig()
147
+ if eventloop is None:
148
+ eventloop = _AWSCRTEventLoop()
149
+ self._eventloop = eventloop
150
+ self._client_bootstrap = self._eventloop.bootstrap
151
+ self._tls_ctx = crt_io.ClientTlsContext(crt_io.TlsContextOptions())
152
+ self._socket_options = crt_io.SocketOptions()
153
+ self._connections: ConnectionPoolDict = {}
154
+
155
+ async def send(
156
+ self,
157
+ request: http_aio_interfaces.HTTPRequest,
158
+ *,
159
+ request_config: http_aio_interfaces.HTTPRequestConfiguration | None = None,
160
+ ) -> AWSCRTHTTPResponse:
161
+ """Send HTTP request using awscrt client.
162
+
163
+ :param request: The request including destination URI, fields, payload.
164
+ :param request_config: Configuration specific to this request.
165
+ """
166
+ crt_request = self._marshal_request(request)
167
+ connection = await self._get_connection(request.destination)
168
+
169
+ # Convert body to async iterator for request_body_generator
170
+ body_generator = self._create_body_generator(request.body)
171
+
172
+ crt_stream = connection.request(
173
+ crt_request,
174
+ request_body_generator=body_generator,
175
+ )
176
+
177
+ return await self._await_response(crt_stream)
178
+
179
+ async def _await_response(
180
+ self, stream: "AIOHttpClientStreamUnified"
181
+ ) -> AWSCRTHTTPResponse:
182
+ status_code = await stream.get_response_status_code()
183
+ headers = await stream.get_response_headers()
184
+ fields = Fields()
185
+ for header_name, header_val in headers:
186
+ try:
187
+ fields[header_name].add(header_val)
188
+ except KeyError:
189
+ fields[header_name] = Field(
190
+ name=header_name,
191
+ values=[header_val],
192
+ kind=FieldPosition.HEADER,
193
+ )
194
+ return AWSCRTHTTPResponse(
195
+ status=status_code,
196
+ fields=fields,
197
+ stream=stream,
198
+ )
199
+
200
+ async def _create_connection(
201
+ self, url: core_interfaces.URI
202
+ ) -> "AIOHttpClientConnectionUnified":
203
+ """Builds and validates connection to ``url``"""
204
+ connection = await self._build_new_connection(url)
205
+ await self._validate_connection(connection)
206
+ return connection
207
+
208
+ async def _get_connection(
209
+ self, url: core_interfaces.URI
210
+ ) -> "AIOHttpClientConnectionUnified":
211
+ # TODO: Use CRT connection pooling instead of this basic kind
212
+ connection_key = (url.scheme, url.host, url.port)
213
+ connection = self._connections.get(connection_key)
214
+
215
+ if connection and connection.is_open():
216
+ return connection
217
+
218
+ connection = await self._create_connection(url)
219
+ self._connections[connection_key] = connection
220
+ return connection
221
+
222
+ async def _build_new_connection(
223
+ self, url: core_interfaces.URI
224
+ ) -> "AIOHttpClientConnectionUnified":
225
+ if url.scheme == "http":
226
+ port = self._HTTP_PORT
227
+ tls_connection_options = None
228
+ elif url.scheme == "https":
229
+ port = self._HTTPS_PORT
230
+ tls_connection_options = self._tls_ctx.new_connection_options()
231
+ tls_connection_options.set_server_name(url.host)
232
+ # TODO: Support TLS configuration, including alpn
233
+ tls_connection_options.set_alpn_list(["h2", "http/1.1"])
234
+ else:
235
+ raise SmithyHTTPError(
236
+ f"AWSCRTHTTPClient does not support URL scheme {url.scheme}"
237
+ )
238
+ if url.port is not None:
239
+ port = url.port
240
+
241
+ return await AIOHttpClientConnectionUnified.new(
242
+ bootstrap=self._client_bootstrap,
243
+ host_name=url.host,
244
+ port=port,
245
+ socket_options=self._socket_options,
246
+ tls_connection_options=tls_connection_options,
247
+ )
248
+
249
+ async def _validate_connection(
250
+ self, connection: "AIOHttpClientConnectionUnified"
251
+ ) -> None:
252
+ """Validates an existing connection against the client config.
253
+
254
+ Checks performed:
255
+ * If ``force_http_2`` is enabled: Is the connection HTTP/2?
256
+ """
257
+ force_http_2 = self._config.force_http_2
258
+ if force_http_2 and connection.version is not crt_http.HttpVersion.Http2:
259
+ await connection.close()
260
+ negotiated = crt_http.HttpVersion(connection.version).name
261
+ raise SmithyHTTPError(f"HTTP/2 could not be negotiated: {negotiated}")
262
+
263
+ def _render_path(self, url: core_interfaces.URI) -> str:
264
+ path = url.path if url.path is not None else "/"
265
+ query = f"?{url.query}" if url.query is not None else ""
266
+ return f"{path}{query}"
267
+
268
+ def _marshal_request(
269
+ self, request: http_aio_interfaces.HTTPRequest
270
+ ) -> "crt_http.HttpRequest":
271
+ """Create :py:class:`awscrt.http.HttpRequest` from
272
+ :py:class:`smithy_http.aio.HTTPRequest`"""
273
+ headers_list: list[tuple[str, str]] = []
274
+ if "host" not in request.fields:
275
+ request.fields.set_field(
276
+ Field(name="host", values=[request.destination.host])
277
+ )
278
+
279
+ if "accept" not in request.fields:
280
+ request.fields.set_field(Field(name="accept", values=["*/*"]))
281
+
282
+ for fld in request.fields.entries.values():
283
+ # TODO: Use literal values for "header"/"trailer".
284
+ if fld.kind.value != FieldPosition.HEADER.value:
285
+ continue
286
+ for val in fld.values:
287
+ headers_list.append((fld.name, val))
288
+
289
+ path = self._render_path(request.destination)
290
+ headers = crt_http.HttpHeaders(headers_list)
291
+
292
+ crt_request = crt_http.HttpRequest(
293
+ method=request.method,
294
+ path=path,
295
+ headers=headers,
296
+ )
297
+ return crt_request
298
+
299
+ async def _create_body_generator(
300
+ self, body: core_aio_interfaces.StreamingBlob
301
+ ) -> AsyncGenerator[bytes, None]:
302
+ """Convert various body types to async generator for request_body_generator."""
303
+ if isinstance(body, bytes):
304
+ # Yield the entire body as a single chunk
305
+ yield body
306
+ elif isinstance(body, bytearray):
307
+ # Convert bytearray to bytes
308
+ yield bytes(body)
309
+ elif isinstance(body, AsyncIterable):
310
+ # Already async iterable, just yield from it.
311
+ # Check this before AsyncByteStream since AsyncBytesReader implements both.
312
+ async for chunk in body:
313
+ if isinstance(chunk, bytearray):
314
+ yield bytes(chunk)
315
+ else:
316
+ yield chunk
317
+ elif iscoroutinefunction(getattr(body, "read", None)) and isinstance(
318
+ body, # type: ignore[reportGeneralTypeIssues]
319
+ core_aio_interfaces.AsyncByteStream, # type: ignore[reportGeneralTypeIssues]
320
+ ):
321
+ # AsyncByteStream has async read method but is not iterable
322
+ while True:
323
+ chunk = await body.read(self._config.read_buffer_size)
324
+ if not chunk:
325
+ break
326
+ if isinstance(chunk, bytearray):
327
+ yield bytes(chunk)
328
+ else:
329
+ yield chunk
330
+ else:
331
+ # Assume it's a sync BytesReader, wrap it in AsyncBytesReader
332
+ async_reader = AsyncBytesReader(body)
333
+ async for chunk in async_reader:
334
+ if isinstance(chunk, bytearray):
335
+ yield bytes(chunk)
336
+ else:
337
+ yield chunk
338
+
339
+ def __deepcopy__(self, memo: Any) -> "AWSCRTHTTPClient":
340
+ return AWSCRTHTTPClient(
341
+ eventloop=self._eventloop,
342
+ client_config=deepcopy(self._config),
343
+ )
@@ -1,15 +0,0 @@
1
- # Changelog
2
-
3
- ## v0.1.0
4
-
5
- ### Breaking Changes
6
- * Removed identity and auth interfaces in favor of the transport-agnostic interfaces introduced in `smithy-core`.
7
-
8
- ### Features
9
- * Introduced schema-based serializers and deserializers for HTTP binding protocols.
10
-
11
- ## v0.0.1
12
-
13
- ### Features
14
- * Added support for aiohttp and AWSCRT http clients.
15
- * Added basic HTTP primitives and interfaces for Smithy clients.
@@ -1,494 +0,0 @@
1
- # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
- # SPDX-License-Identifier: Apache-2.0
3
- # pyright: reportMissingTypeStubs=false,reportUnknownMemberType=false
4
- # flake8: noqa: F811
5
- import asyncio
6
- from asyncio import Future as AsyncFuture
7
- from collections import deque
8
- from collections.abc import AsyncGenerator, AsyncIterable
9
- from concurrent.futures import Future as ConcurrentFuture
10
- from copy import deepcopy
11
- from functools import partial
12
- from io import BufferedIOBase, BytesIO
13
- from typing import TYPE_CHECKING, Any, cast
14
-
15
- if TYPE_CHECKING:
16
- # Both of these are types that essentially are "castable to bytes/memoryview"
17
- # Unfortunately they're not exposed anywhere so we have to import them from
18
- # _typeshed.
19
- from _typeshed import ReadableBuffer, WriteableBuffer
20
-
21
- # pyright doesn't like optional imports. This is reasonable because if we use these
22
- # in type hints then they'd result in runtime errors.
23
- # TODO: add integ tests that import these without the dependendency installed
24
- from awscrt import http as crt_http
25
- from awscrt import io as crt_io
26
-
27
- try:
28
- from awscrt import http as crt_http
29
- from awscrt import io as crt_io
30
-
31
- HAS_CRT = True
32
- except ImportError:
33
- HAS_CRT = False # type: ignore
34
-
35
- from smithy_core import interfaces as core_interfaces
36
- from smithy_core.aio.types import AsyncBytesReader
37
- from smithy_core.aio.utils import close
38
- from smithy_core.exceptions import MissingDependencyError
39
-
40
- from .. import Field, Fields
41
- from .. import interfaces as http_interfaces
42
- from ..exceptions import SmithyHTTPError
43
- from ..interfaces import FieldPosition
44
- from . import interfaces as http_aio_interfaces
45
-
46
-
47
- def _assert_crt() -> None:
48
- if not HAS_CRT:
49
- raise MissingDependencyError(
50
- "Attempted to use awscrt component, but awscrt is not installed."
51
- )
52
-
53
-
54
- class _AWSCRTEventLoop:
55
- def __init__(self) -> None:
56
- _assert_crt()
57
- self.bootstrap = self._initialize_default_loop()
58
-
59
- def _initialize_default_loop(self) -> "crt_io.ClientBootstrap":
60
- event_loop_group = crt_io.EventLoopGroup(1)
61
- host_resolver = crt_io.DefaultHostResolver(event_loop_group)
62
- return crt_io.ClientBootstrap(event_loop_group, host_resolver)
63
-
64
-
65
- class AWSCRTHTTPResponse(http_aio_interfaces.HTTPResponse):
66
- def __init__(self, *, status: int, fields: Fields, body: "CRTResponseBody") -> None:
67
- _assert_crt()
68
- self._status = status
69
- self._fields = fields
70
- self._body = body
71
-
72
- @property
73
- def status(self) -> int:
74
- return self._status
75
-
76
- @property
77
- def fields(self) -> Fields:
78
- return self._fields
79
-
80
- @property
81
- def body(self) -> AsyncIterable[bytes]:
82
- return self.chunks()
83
-
84
- @property
85
- def reason(self) -> str | None:
86
- """Optional string provided by the server explaining the status."""
87
- # TODO: See how CRT exposes reason.
88
- return None
89
-
90
- async def chunks(self) -> AsyncGenerator[bytes, None]:
91
- while True:
92
- chunk = await self._body.next()
93
- if chunk:
94
- yield chunk
95
- else:
96
- break
97
-
98
- def __repr__(self) -> str:
99
- return (
100
- f"AWSCRTHTTPResponse("
101
- f"status={self.status}, "
102
- f"fields={self.fields!r}, body=...)"
103
- )
104
-
105
-
106
- class CRTResponseBody:
107
- def __init__(self) -> None:
108
- self._stream: crt_http.HttpClientStream | None = None
109
- self._completion_future: AsyncFuture[int] | None = None
110
- self._chunk_futures: deque[ConcurrentFuture[bytes]] = deque()
111
-
112
- # deque is thread safe and the crt is only going to be writing
113
- # with one thread anyway, so we *shouldn't* need to gate this
114
- # behind a lock. In an ideal world, the CRT would expose
115
- # an interface that better matches python's async.
116
- self._received_chunks: deque[bytes] = deque()
117
-
118
- def set_stream(self, stream: "crt_http.HttpClientStream") -> None:
119
- if self._stream is not None:
120
- raise SmithyHTTPError("Stream already set on AWSCRTHTTPResponse object")
121
- self._stream = stream
122
- concurrent_future = cast(ConcurrentFuture[int], stream.completion_future)
123
- self._completion_future = asyncio.wrap_future(concurrent_future)
124
- self._completion_future.add_done_callback(self._on_complete)
125
- self._stream.activate()
126
-
127
- def on_body(self, chunk: bytes, **kwargs: Any) -> None: # pragma: crt-callback
128
- # TODO: update back pressure window once CRT supports it
129
- if self._chunk_futures:
130
- future = self._chunk_futures.popleft()
131
- future.set_result(chunk)
132
- else:
133
- self._received_chunks.append(chunk)
134
-
135
- async def next(self) -> bytes:
136
- if self._completion_future is None:
137
- raise SmithyHTTPError("Stream not set")
138
-
139
- # TODO: update backpressure window once CRT supports it
140
- if self._received_chunks:
141
- return self._received_chunks.popleft()
142
- elif self._completion_future.done():
143
- return b""
144
- else:
145
- future = ConcurrentFuture[bytes]()
146
- self._chunk_futures.append(future)
147
- return await asyncio.wrap_future(future)
148
-
149
- def _on_complete(
150
- self, completion_future: AsyncFuture[int]
151
- ) -> None: # pragma: crt-callback
152
- for future in self._chunk_futures:
153
- future.set_result(b"")
154
- self._chunk_futures.clear()
155
-
156
-
157
- class CRTResponseFactory:
158
- def __init__(self, body: CRTResponseBody) -> None:
159
- self._body = body
160
- self._response_future = ConcurrentFuture[AWSCRTHTTPResponse]()
161
-
162
- def on_response(
163
- self, status_code: int, headers: list[tuple[str, str]], **kwargs: Any
164
- ) -> None: # pragma: crt-callback
165
- fields = Fields()
166
- for header_name, header_val in headers:
167
- try:
168
- fields[header_name].add(header_val)
169
- except KeyError:
170
- fields[header_name] = Field(
171
- name=header_name,
172
- values=[header_val],
173
- kind=FieldPosition.HEADER,
174
- )
175
-
176
- self._response_future.set_result(
177
- AWSCRTHTTPResponse(
178
- status=status_code,
179
- fields=fields,
180
- body=self._body,
181
- )
182
- )
183
-
184
- async def await_response(self) -> AWSCRTHTTPResponse:
185
- return await asyncio.wrap_future(self._response_future)
186
-
187
- def set_done_callback(self, stream: "crt_http.HttpClientStream") -> None:
188
- stream.completion_future.add_done_callback(self._cancel)
189
-
190
- def _cancel(self, completion_future: ConcurrentFuture[int | Exception]) -> None:
191
- if not self._response_future.done():
192
- self._response_future.cancel()
193
-
194
-
195
- ConnectionPoolKey = tuple[str, str, int | None]
196
- ConnectionPoolDict = dict[ConnectionPoolKey, "crt_http.HttpClientConnection"]
197
-
198
-
199
- class AWSCRTHTTPClientConfig(http_interfaces.HTTPClientConfiguration):
200
- def __post_init__(self) -> None:
201
- _assert_crt()
202
-
203
-
204
- class AWSCRTHTTPClient(http_aio_interfaces.HTTPClient):
205
- _HTTP_PORT = 80
206
- _HTTPS_PORT = 443
207
-
208
- def __init__(
209
- self,
210
- eventloop: _AWSCRTEventLoop | None = None,
211
- client_config: AWSCRTHTTPClientConfig | None = None,
212
- ) -> None:
213
- """
214
- :param client_config: Configuration that applies to all requests made with this
215
- client.
216
- """
217
- _assert_crt()
218
- self._config = client_config or AWSCRTHTTPClientConfig()
219
- if eventloop is None:
220
- eventloop = _AWSCRTEventLoop()
221
- self._eventloop = eventloop
222
- self._client_bootstrap = self._eventloop.bootstrap
223
- self._tls_ctx = crt_io.ClientTlsContext(crt_io.TlsContextOptions())
224
- self._socket_options = crt_io.SocketOptions()
225
- self._connections: ConnectionPoolDict = {}
226
- self._async_reads: set[asyncio.Task[Any]] = set()
227
-
228
- async def send(
229
- self,
230
- request: http_aio_interfaces.HTTPRequest,
231
- *,
232
- request_config: http_aio_interfaces.HTTPRequestConfiguration | None = None,
233
- ) -> AWSCRTHTTPResponse:
234
- """Send HTTP request using awscrt client.
235
-
236
- :param request: The request including destination URI, fields, payload.
237
- :param request_config: Configuration specific to this request.
238
- """
239
- crt_request, crt_body = await self._marshal_request(request)
240
- connection = await self._get_connection(request.destination)
241
- response_body = CRTResponseBody()
242
- response_factory = CRTResponseFactory(response_body)
243
- crt_stream = connection.request(
244
- crt_request,
245
- response_factory.on_response,
246
- response_body.on_body,
247
- )
248
- response_factory.set_done_callback(crt_stream)
249
- response_body.set_stream(crt_stream)
250
- crt_stream.completion_future.add_done_callback(
251
- partial(self._close_input_body, body=crt_body)
252
- )
253
-
254
- response = await response_factory.await_response()
255
- if response.status != 200 and response.status >= 300:
256
- await close(crt_body)
257
-
258
- return response
259
-
260
- def _close_input_body(
261
- self, future: ConcurrentFuture[int], *, body: "BufferableByteStream | BytesIO"
262
- ) -> None:
263
- if future.exception(timeout=0):
264
- body.close()
265
-
266
- async def _create_connection(
267
- self, url: core_interfaces.URI
268
- ) -> "crt_http.HttpClientConnection":
269
- """Builds and validates connection to ``url``"""
270
- connect_future = self._build_new_connection(url)
271
- connection = await asyncio.wrap_future(connect_future)
272
- self._validate_connection(connection)
273
- return connection
274
-
275
- async def _get_connection(
276
- self, url: core_interfaces.URI
277
- ) -> "crt_http.HttpClientConnection":
278
- # TODO: Use CRT connection pooling instead of this basic kind
279
- connection_key = (url.scheme, url.host, url.port)
280
- connection = self._connections.get(connection_key)
281
-
282
- if connection and connection.is_open():
283
- return connection
284
-
285
- connection = await self._create_connection(url)
286
- self._connections[connection_key] = connection
287
- return connection
288
-
289
- def _build_new_connection(
290
- self, url: core_interfaces.URI
291
- ) -> ConcurrentFuture["crt_http.HttpClientConnection"]:
292
- if url.scheme == "http":
293
- port = self._HTTP_PORT
294
- tls_connection_options = None
295
- elif url.scheme == "https":
296
- port = self._HTTPS_PORT
297
- tls_connection_options = self._tls_ctx.new_connection_options()
298
- tls_connection_options.set_server_name(url.host)
299
- # TODO: Support TLS configuration, including alpn
300
- tls_connection_options.set_alpn_list(["h2", "http/1.1"])
301
- else:
302
- raise SmithyHTTPError(
303
- f"AWSCRTHTTPClient does not support URL scheme {url.scheme}"
304
- )
305
- if url.port is not None:
306
- port = url.port
307
-
308
- connect_future = cast(
309
- ConcurrentFuture[crt_http.HttpClientConnection],
310
- crt_http.HttpClientConnection.new(
311
- bootstrap=self._client_bootstrap,
312
- host_name=url.host,
313
- port=port,
314
- socket_options=self._socket_options,
315
- tls_connection_options=tls_connection_options,
316
- ),
317
- )
318
- return connect_future
319
-
320
- def _validate_connection(self, connection: "crt_http.HttpClientConnection") -> None:
321
- """Validates an existing connection against the client config.
322
-
323
- Checks performed:
324
- * If ``force_http_2`` is enabled: Is the connection HTTP/2?
325
- """
326
- force_http_2 = self._config.force_http_2
327
- if force_http_2 and connection.version is not crt_http.HttpVersion.Http2:
328
- connection.close()
329
- negotiated = crt_http.HttpVersion(connection.version).name
330
- raise SmithyHTTPError(f"HTTP/2 could not be negotiated: {negotiated}")
331
-
332
- def _render_path(self, url: core_interfaces.URI) -> str:
333
- path = url.path if url.path is not None else "/"
334
- query = f"?{url.query}" if url.query is not None else ""
335
- return f"{path}{query}"
336
-
337
- async def _marshal_request(
338
- self, request: http_aio_interfaces.HTTPRequest
339
- ) -> tuple["crt_http.HttpRequest", "BufferableByteStream | BytesIO"]:
340
- """Create :py:class:`awscrt.http.HttpRequest` from
341
- :py:class:`smithy_http.aio.HTTPRequest`"""
342
- headers_list = []
343
- if "host" not in request.fields:
344
- request.fields.set_field(
345
- Field(name="host", values=[request.destination.host])
346
- )
347
-
348
- if "accept" not in request.fields:
349
- request.fields.set_field(Field(name="accept", values=["*/*"]))
350
-
351
- for fld in request.fields.entries.values():
352
- # TODO: Use literal values for "header"/"trailer".
353
- if fld.kind.value != FieldPosition.HEADER.value:
354
- continue
355
- for val in fld.values:
356
- headers_list.append((fld.name, val))
357
-
358
- path = self._render_path(request.destination)
359
- headers = crt_http.HttpHeaders(headers_list)
360
-
361
- body = request.body
362
- if isinstance(body, bytes | bytearray):
363
- # If the body is already directly in memory, wrap in a BytesIO to hand
364
- # off to CRT.
365
- crt_body = BytesIO(body)
366
- else:
367
- # If the body is async, or potentially very large, start up a task to read
368
- # it into the intermediate object that CRT needs. By using
369
- # asyncio.create_task we'll start the coroutine without having to
370
- # explicitly await it.
371
- crt_body = BufferableByteStream()
372
-
373
- if not isinstance(body, AsyncIterable):
374
- body = AsyncBytesReader(body)
375
-
376
- # Start the read task in the background.
377
- read_task = asyncio.create_task(self._consume_body_async(body, crt_body))
378
-
379
- # Keep track of the read task so that it doesn't get garbage colllected,
380
- # and stop tracking it once it's done.
381
- self._async_reads.add(read_task)
382
- read_task.add_done_callback(self._async_reads.discard)
383
-
384
- crt_request = crt_http.HttpRequest(
385
- method=request.method,
386
- path=path,
387
- headers=headers,
388
- body_stream=crt_body,
389
- )
390
- return crt_request, crt_body
391
-
392
- async def _consume_body_async(
393
- self, source: AsyncIterable[bytes], dest: "BufferableByteStream"
394
- ) -> None:
395
- try:
396
- async for chunk in source:
397
- dest.write(chunk)
398
- except Exception:
399
- dest.close()
400
- raise
401
- finally:
402
- await close(source)
403
- dest.end_stream()
404
-
405
- def __deepcopy__(self, memo: Any) -> "AWSCRTHTTPClient":
406
- return AWSCRTHTTPClient(
407
- eventloop=self._eventloop,
408
- client_config=deepcopy(self._config),
409
- )
410
-
411
-
412
- # This is adapted from the transcribe streaming sdk
413
- class BufferableByteStream(BufferedIOBase):
414
- """A non-blocking bytes buffer."""
415
-
416
- def __init__(self) -> None:
417
- # We're always manipulating the front and back of the buffer, so a deque
418
- # will be much more efficient than a list.
419
- self._chunks: deque[bytes] = deque()
420
- self._closed = False
421
- self._done = False
422
-
423
- def read(self, size: int | None = -1) -> bytes:
424
- if self._closed:
425
- return b""
426
-
427
- if len(self._chunks) == 0:
428
- if self._done:
429
- self.close()
430
- return b""
431
- else:
432
- # When the CRT recieves this, it'll try again
433
- raise BlockingIOError("read")
434
-
435
- # We could compile all the chunks here instead of just returning
436
- # the one, BUT the CRT will keep calling read until empty bytes
437
- # are returned. So it's actually better to just return one chunk
438
- # since combining them would have some potentially bad memory
439
- # usage issues.
440
- result = self._chunks.popleft()
441
- if size is not None and size > 0:
442
- remainder = result[size:]
443
- result = result[:size]
444
- if remainder:
445
- self._chunks.appendleft(remainder)
446
-
447
- if self._done and len(self._chunks) == 0:
448
- self.close()
449
-
450
- return result
451
-
452
- def read1(self, size: int = -1) -> bytes:
453
- return self.read(size)
454
-
455
- def readinto(self, buffer: "WriteableBuffer") -> int:
456
- if not isinstance(buffer, memoryview):
457
- buffer = memoryview(buffer).cast("B")
458
-
459
- data = self.read(len(buffer)) # type: ignore
460
- n = len(data)
461
- buffer[:n] = data
462
- return n
463
-
464
- def write(self, buffer: "ReadableBuffer") -> int:
465
- if not isinstance(buffer, bytes):
466
- raise ValueError(
467
- f"Unexpected value written to BufferableByteStream. "
468
- f"Only bytes are support but {type(buffer)} was provided."
469
- )
470
-
471
- if self._closed:
472
- raise OSError("Stream is completed and doesn't support further writes.")
473
-
474
- if buffer:
475
- self._chunks.append(buffer)
476
- return len(buffer)
477
-
478
- @property
479
- def closed(self) -> bool:
480
- return self._closed
481
-
482
- def close(self) -> None:
483
- self._closed = True
484
- self._done = True
485
-
486
- # Clear out the remaining chunks so that they don't sit around in memory.
487
- self._chunks.clear()
488
-
489
- def end_stream(self) -> None:
490
- """End the stream, letting any remaining chunks be read before it is closed."""
491
- if len(self._chunks) == 0:
492
- self.close()
493
- else:
494
- self._done = True
File without changes
File without changes
File without changes