smithy-http 0.0.1__py3-none-any.whl

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.
smithy_http/aio/crt.py ADDED
@@ -0,0 +1,493 @@
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
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 MissingDependencyException
39
+
40
+ from .. import Field, Fields
41
+ from .. import interfaces as http_interfaces
42
+ from ..exceptions import SmithyHTTPException
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 MissingDependencyException(
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 SmithyHTTPException("Stream already set on AWSCRTHTTPResponse object")
121
+ self._stream = stream
122
+ concurrent_future: 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 SmithyHTTPException("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 SmithyHTTPException(
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: ConcurrentFuture[crt_http.HttpClientConnection] = (
309
+ crt_http.HttpClientConnection.new(
310
+ bootstrap=self._client_bootstrap,
311
+ host_name=url.host,
312
+ port=port,
313
+ socket_options=self._socket_options,
314
+ tls_connection_options=tls_connection_options,
315
+ )
316
+ )
317
+ return connect_future
318
+
319
+ def _validate_connection(self, connection: "crt_http.HttpClientConnection") -> None:
320
+ """Validates an existing connection against the client config.
321
+
322
+ Checks performed:
323
+ * If ``force_http_2`` is enabled: Is the connection HTTP/2?
324
+ """
325
+ force_http_2 = self._config.force_http_2
326
+ if force_http_2 and connection.version is not crt_http.HttpVersion.Http2:
327
+ connection.close()
328
+ negotiated = crt_http.HttpVersion(connection.version).name
329
+ raise SmithyHTTPException(f"HTTP/2 could not be negotiated: {negotiated}")
330
+
331
+ def _render_path(self, url: core_interfaces.URI) -> str:
332
+ path = url.path if url.path is not None else "/"
333
+ query = f"?{url.query}" if url.query is not None else ""
334
+ return f"{path}{query}"
335
+
336
+ async def _marshal_request(
337
+ self, request: http_aio_interfaces.HTTPRequest
338
+ ) -> tuple["crt_http.HttpRequest", "BufferableByteStream | BytesIO"]:
339
+ """Create :py:class:`awscrt.http.HttpRequest` from
340
+ :py:class:`smithy_http.aio.HTTPRequest`"""
341
+ headers_list = []
342
+ if "host" not in request.fields:
343
+ request.fields.set_field(
344
+ Field(name="host", values=[request.destination.host])
345
+ )
346
+
347
+ if "accept" not in request.fields:
348
+ request.fields.set_field(Field(name="accept", values=["*/*"]))
349
+
350
+ for fld in request.fields.entries.values():
351
+ # TODO: Use literal values for "header"/"trailer".
352
+ if fld.kind.value != FieldPosition.HEADER.value:
353
+ continue
354
+ for val in fld.values:
355
+ headers_list.append((fld.name, val))
356
+
357
+ path = self._render_path(request.destination)
358
+ headers = crt_http.HttpHeaders(headers_list)
359
+
360
+ body = request.body
361
+ if isinstance(body, bytes | bytearray):
362
+ # If the body is already directly in memory, wrap in a BytesIO to hand
363
+ # off to CRT.
364
+ crt_body = BytesIO(body)
365
+ else:
366
+ # If the body is async, or potentially very large, start up a task to read
367
+ # it into the intermediate object that CRT needs. By using
368
+ # asyncio.create_task we'll start the coroutine without having to
369
+ # explicitly await it.
370
+ crt_body = BufferableByteStream()
371
+
372
+ if not isinstance(body, AsyncIterable):
373
+ body = AsyncBytesReader(body)
374
+
375
+ # Start the read task in the background.
376
+ read_task = asyncio.create_task(self._consume_body_async(body, crt_body))
377
+
378
+ # Keep track of the read task so that it doesn't get garbage colllected,
379
+ # and stop tracking it once it's done.
380
+ self._async_reads.add(read_task)
381
+ read_task.add_done_callback(self._async_reads.discard)
382
+
383
+ crt_request = crt_http.HttpRequest(
384
+ method=request.method,
385
+ path=path,
386
+ headers=headers,
387
+ body_stream=crt_body,
388
+ )
389
+ return crt_request, crt_body
390
+
391
+ async def _consume_body_async(
392
+ self, source: AsyncIterable[bytes], dest: "BufferableByteStream"
393
+ ) -> None:
394
+ try:
395
+ async for chunk in source:
396
+ dest.write(chunk)
397
+ except Exception:
398
+ dest.close()
399
+ raise
400
+ finally:
401
+ await close(source)
402
+ dest.end_stream()
403
+
404
+ def __deepcopy__(self, memo: Any) -> "AWSCRTHTTPClient":
405
+ return AWSCRTHTTPClient(
406
+ eventloop=self._eventloop,
407
+ client_config=deepcopy(self._config),
408
+ )
409
+
410
+
411
+ # This is adapted from the transcribe streaming sdk
412
+ class BufferableByteStream(BufferedIOBase):
413
+ """A non-blocking bytes buffer."""
414
+
415
+ def __init__(self) -> None:
416
+ # We're always manipulating the front and back of the buffer, so a deque
417
+ # will be much more efficient than a list.
418
+ self._chunks: deque[bytes] = deque()
419
+ self._closed = False
420
+ self._done = False
421
+
422
+ def read(self, size: int | None = -1) -> bytes:
423
+ if self._closed:
424
+ return b""
425
+
426
+ if len(self._chunks) == 0:
427
+ if self._done:
428
+ self.close()
429
+ return b""
430
+ else:
431
+ # When the CRT recieves this, it'll try again
432
+ raise BlockingIOError("read")
433
+
434
+ # We could compile all the chunks here instead of just returning
435
+ # the one, BUT the CRT will keep calling read until empty bytes
436
+ # are returned. So it's actually better to just return one chunk
437
+ # since combining them would have some potentially bad memory
438
+ # usage issues.
439
+ result = self._chunks.popleft()
440
+ if size is not None and size > 0:
441
+ remainder = result[size:]
442
+ result = result[:size]
443
+ if remainder:
444
+ self._chunks.appendleft(remainder)
445
+
446
+ if self._done and len(self._chunks) == 0:
447
+ self.close()
448
+
449
+ return result
450
+
451
+ def read1(self, size: int = -1) -> bytes:
452
+ return self.read(size)
453
+
454
+ def readinto(self, buffer: "WriteableBuffer") -> int:
455
+ if not isinstance(buffer, memoryview):
456
+ buffer = memoryview(buffer).cast("B")
457
+
458
+ data = self.read(len(buffer)) # type: ignore
459
+ n = len(data)
460
+ buffer[:n] = data
461
+ return n
462
+
463
+ def write(self, buffer: "ReadableBuffer") -> int:
464
+ if not isinstance(buffer, bytes):
465
+ raise ValueError(
466
+ f"Unexpected value written to BufferableByteStream. "
467
+ f"Only bytes are support but {type(buffer)} was provided."
468
+ )
469
+
470
+ if self._closed:
471
+ raise OSError("Stream is completed and doesn't support further writes.")
472
+
473
+ if buffer:
474
+ self._chunks.append(buffer)
475
+ return len(buffer)
476
+
477
+ @property
478
+ def closed(self) -> bool:
479
+ return self._closed
480
+
481
+ def close(self) -> None:
482
+ self._closed = True
483
+ self._done = True
484
+
485
+ # Clear out the remaining chunks so that they don't sit around in memory.
486
+ self._chunks.clear()
487
+
488
+ def end_stream(self) -> None:
489
+ """End the stream, letting any remaining chunks be read before it is closed."""
490
+ if len(self._chunks) == 0:
491
+ self.close()
492
+ else:
493
+ self._done = True
@@ -0,0 +1,2 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,38 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from smithy_core.aio.interfaces.identity import IdentityResolver
4
+ from smithy_core.identity import Identity
5
+ from smithy_core.interfaces.identity import IdentityProperties
6
+
7
+
8
+ class ApiKeyIdentity(Identity):
9
+ """The identity for auth that uses an api key."""
10
+
11
+ def __init__(self, *, api_key: str) -> None:
12
+ super().__init__(expiration=None)
13
+ self.api_key = api_key
14
+
15
+
16
+ class ApiKeyIdentityResolver(IdentityResolver[ApiKeyIdentity, IdentityProperties]):
17
+ """Loads the api key identity from the configuration."""
18
+
19
+ def __init__(self, *, api_key: str | ApiKeyIdentity) -> None:
20
+ """
21
+ :param api_key: The API key to authenticate with.
22
+ """
23
+ match api_key:
24
+ case str():
25
+ self._identity = ApiKeyIdentity(api_key=api_key)
26
+ case ApiKeyIdentity():
27
+ self._identity = api_key
28
+
29
+ async def get_identity(
30
+ self, *, identity_properties: IdentityProperties
31
+ ) -> ApiKeyIdentity:
32
+ """Load the user's api key identity from this resolver.
33
+
34
+ :param identity_properties: Properties used to help determine the identity to
35
+ return.
36
+ :returns: The api key identity.
37
+ """
38
+ return self._identity
@@ -0,0 +1,85 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ from typing import Protocol
4
+
5
+ from smithy_core.aio.interfaces import ClientTransport, Request, Response
6
+ from smithy_core.aio.utils import read_streaming_blob, read_streaming_blob_async
7
+
8
+ from ...interfaces import (
9
+ Fields,
10
+ HTTPClientConfiguration,
11
+ HTTPRequestConfiguration,
12
+ )
13
+
14
+
15
+ class HTTPRequest(Request, Protocol):
16
+ """HTTP primitive for an Exchange to construct a version agnostic HTTP message.
17
+
18
+ :param destination: The URI where the request should be sent to.
19
+ :param method: The HTTP method of the request, for example "GET".
20
+ :param fields: ``Fields`` object containing HTTP headers and trailers.
21
+ :param body: A streamable collection of bytes.
22
+ """
23
+
24
+ method: str
25
+ fields: Fields
26
+
27
+ async def consume_body_async(self) -> bytes:
28
+ """Iterate over request body and return as bytes."""
29
+ return await read_streaming_blob_async(self.body)
30
+
31
+ def consume_body(self) -> bytes:
32
+ """Iterate over request body and return as bytes."""
33
+ return read_streaming_blob(self.body)
34
+
35
+
36
+ class HTTPResponse(Response, Protocol):
37
+ """HTTP primitives returned from an Exchange, used to construct a client
38
+ response."""
39
+
40
+ @property
41
+ def status(self) -> int:
42
+ """The 3 digit response status code (1xx, 2xx, 3xx, 4xx, 5xx)."""
43
+ ...
44
+
45
+ @property
46
+ def fields(self) -> Fields:
47
+ """``Fields`` object containing HTTP headers and trailers."""
48
+ ...
49
+
50
+ @property
51
+ def reason(self) -> str | None:
52
+ """Optional string provided by the server explaining the status."""
53
+ ...
54
+
55
+ async def consume_body_async(self) -> bytes:
56
+ """Iterate over request body and return as bytes."""
57
+ return await read_streaming_blob_async(self.body)
58
+
59
+ def consume_body(self) -> bytes:
60
+ """Iterate over request body and return as bytes."""
61
+ return read_streaming_blob(self.body)
62
+
63
+
64
+ class HTTPClient(ClientTransport[HTTPRequest, HTTPResponse], Protocol):
65
+ """An asynchronous HTTP client interface."""
66
+
67
+ def __init__(self, *, client_config: HTTPClientConfiguration | None) -> None:
68
+ """
69
+ :param client_config: Configuration that applies to all requests made with this
70
+ client.
71
+ """
72
+ ...
73
+
74
+ async def send(
75
+ self,
76
+ request: HTTPRequest,
77
+ *,
78
+ request_config: HTTPRequestConfiguration | None = None,
79
+ ) -> HTTPResponse:
80
+ """Send HTTP request over the wire and return the response.
81
+
82
+ :param request: The request including destination URI, fields, payload.
83
+ :param request_config: Configuration specific to this request.
84
+ """
85
+ ...