smithy-http 0.4.4__tar.gz → 0.5.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 (45) hide show
  1. smithy_http-0.5.0/.changes/0.5.0.json +20 -0
  2. {smithy_http-0.4.4 → smithy_http-0.5.0}/CHANGELOG.md +10 -0
  3. {smithy_http-0.4.4 → smithy_http-0.5.0}/PKG-INFO +1 -1
  4. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/__init__.py +1 -1
  5. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/aiohttp.py +55 -17
  6. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/crt.py +28 -1
  7. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/serializers.py +6 -2
  8. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.0.1.json +0 -0
  9. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.1.0.json +0 -0
  10. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.2.0.json +0 -0
  11. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.2.1.json +0 -0
  12. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.3.0.json +0 -0
  13. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.3.1.json +0 -0
  14. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.4.0.json +0 -0
  15. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.4.1.json +0 -0
  16. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.4.2.json +0 -0
  17. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.4.3.json +0 -0
  18. {smithy_http-0.4.4 → smithy_http-0.5.0}/.changes/0.4.4.json +0 -0
  19. {smithy_http-0.4.4 → smithy_http-0.5.0}/.gitignore +0 -0
  20. {smithy_http-0.4.4 → smithy_http-0.5.0}/NOTICE +0 -0
  21. {smithy_http-0.4.4 → smithy_http-0.5.0}/README.md +0 -0
  22. {smithy_http-0.4.4 → smithy_http-0.5.0}/pyproject.toml +0 -0
  23. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/__init__.py +0 -0
  24. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/auth/__init__.py +0 -0
  25. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/auth/apikey.py +0 -0
  26. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/identity/__init__.py +0 -0
  27. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/identity/apikey.py +0 -0
  28. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/interfaces/__init__.py +0 -0
  29. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/protocols.py +0 -0
  30. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/aio/restjson.py +0 -0
  31. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/bindings.py +0 -0
  32. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/deserializers.py +0 -0
  33. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/endpoints.py +0 -0
  34. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/exceptions.py +0 -0
  35. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/interceptors/__init__.py +0 -0
  36. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/interceptors/user_agent.py +0 -0
  37. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/interfaces/__init__.py +0 -0
  38. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/plugins/__init__.py +0 -0
  39. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/py.typed +0 -0
  40. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/restjson.py +0 -0
  41. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/testing/__init__.py +0 -0
  42. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/testing/mockhttp.py +0 -0
  43. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/testing/utils.py +0 -0
  44. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/user_agent.py +0 -0
  45. {smithy_http-0.4.4 → smithy_http-0.5.0}/src/smithy_http/utils.py +0 -0
@@ -0,0 +1,20 @@
1
+ {
2
+ "changes": [
3
+ {
4
+ "type": "feature",
5
+ "description": "Added deterministic connection-pool cleanup and async context manager support to HTTP clients."
6
+ },
7
+ {
8
+ "type": "bugfix",
9
+ "description": "Stream aiohttp response bodies incrementally to enable true output event streams."
10
+ },
11
+ {
12
+ "type": "bugfix",
13
+ "description": "Fixed `AIOHTTPClient` to stop dropping valueless query parameters and automatically adding a `Content-Type` to bodyless POST, PUT, and PATCH requests."
14
+ },
15
+ {
16
+ "type": "bugfix",
17
+ "description": "Fixed duplicate Content-Type headers when a member is bound to Content-Type."
18
+ }
19
+ ]
20
+ }
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.5.0
4
+
5
+ ### Features
6
+ * Added deterministic connection-pool cleanup and async context manager support to HTTP clients.
7
+
8
+ ### Bug fixes
9
+ * Stream aiohttp response bodies incrementally to enable true output event streams.
10
+ * Fixed `AIOHTTPClient` to stop dropping valueless query parameters and automatically adding a `Content-Type` to bodyless POST, PUT, and PATCH requests.
11
+ * Fixed duplicate Content-Type headers when a member is bound to Content-Type.
12
+
3
13
  ## v0.4.4
4
14
 
5
15
  ### Enhancements
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: smithy-http
3
- Version: 0.4.4
3
+ Version: 0.5.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/
@@ -7,7 +7,7 @@ from typing import get_args
7
7
  from . import interfaces
8
8
  from .interfaces import FieldPosition
9
9
 
10
- __version__ = "0.4.4"
10
+ __version__ = "0.5.0"
11
11
 
12
12
  _VALID_FIELD_POSITIONS = frozenset(get_args(FieldPosition))
13
13
 
@@ -1,9 +1,9 @@
1
1
  # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  # SPDX-License-Identifier: Apache-2.0
3
+ from collections.abc import AsyncIterable, AsyncIterator
3
4
  from copy import copy, deepcopy
4
5
  from itertools import chain
5
- from typing import TYPE_CHECKING, Any
6
- from urllib.parse import parse_qs
6
+ from typing import TYPE_CHECKING, Any, Self
7
7
 
8
8
  import yarl
9
9
 
@@ -22,11 +22,11 @@ except ImportError:
22
22
 
23
23
  from smithy_core.aio.interfaces import StreamingBlob
24
24
  from smithy_core.aio.types import AsyncBytesReader
25
- from smithy_core.aio.utils import async_list
26
25
  from smithy_core.exceptions import MissingDependencyError
27
26
  from smithy_core.interfaces import URI
28
27
 
29
28
  from .. import Field, Fields
29
+ from ..exceptions import SmithyHTTPError
30
30
  from ..interfaces import (
31
31
  HTTPClientConfiguration,
32
32
  HTTPRequestConfiguration,
@@ -48,13 +48,28 @@ class AIOHTTPClientConfig(HTTPClientConfiguration):
48
48
  _assert_aiohttp()
49
49
 
50
50
 
51
+ class _AIOHTTPStreamingBody(AsyncIterable[bytes]):
52
+ """Streams a response body, releasing the response once it is done."""
53
+
54
+ def __init__(self, response: "aiohttp.ClientResponse") -> None:
55
+ self._response = response
56
+
57
+ def __aiter__(self) -> AsyncIterator[bytes]:
58
+ # The reader iterates by line, so use iter_any to get raw chunks.
59
+ return self._response.content.iter_any()
60
+
61
+ async def close(self) -> None:
62
+ # Pools the connection if the body was read to completion, closes it if not.
63
+ self._response.release()
64
+
65
+
51
66
  class AIOHTTPClient(HTTPClient):
52
67
  """Implementation of :py:class:`.interfaces.HTTPClient` using aiohttp."""
53
68
 
54
69
  TIMEOUT_EXCEPTIONS = (TimeoutError,)
55
70
 
56
- # aiohttp has no HTTP/2 support and this client fully buffers the response
57
- # before returning, so it can never interleave request and response data.
71
+ # aiohttp has no HTTP/2 support, so it can never interleave request and
72
+ # response data.
58
73
  SUPPORTS_DUPLEX_STREAMING = False
59
74
 
60
75
  def __init__(
@@ -69,6 +84,7 @@ class AIOHTTPClient(HTTPClient):
69
84
  """
70
85
  _assert_aiohttp()
71
86
  self._config = client_config or AIOHTTPClientConfig()
87
+ self._closed = False
72
88
  # Disable transparent response decompression and advertise
73
89
  # 'identity' to request uncompressed responses.
74
90
  # TODO: add a functional test once the test client framework exists
@@ -88,6 +104,11 @@ class AIOHTTPClient(HTTPClient):
88
104
  :param request: The request including destination URI, fields, payload.
89
105
  :param request_config: Configuration specific to this request.
90
106
  """
107
+ if self._closed:
108
+ raise SmithyHTTPError(
109
+ "Cannot send a request after the HTTP client has been closed."
110
+ )
111
+
91
112
  request_config = request_config or HTTPRequestConfiguration()
92
113
 
93
114
  headers_list = list(
@@ -103,18 +124,34 @@ class AIOHTTPClient(HTTPClient):
103
124
  elif not isinstance(body, AsyncBytesReader):
104
125
  body = AsyncBytesReader(body)
105
126
 
106
- # The typing on `params` is incorrect, it'll happily accept a mapping whose
107
- # values are lists (or tuples) and produce expected values.
108
- # See: https://github.com/aio-libs/aiohttp/issues/8563
109
- async with self._session.request(
127
+ resp = await self._session.request(
110
128
  method=request.method,
111
- url=self._serialize_uri_without_query(request.destination),
112
- params=parse_qs(request.destination.query), # type: ignore
129
+ url=self._serialize_uri(request.destination),
113
130
  headers=headers_list,
114
131
  data=body,
115
132
  allow_redirects=False,
116
- ) as resp:
117
- return await self._marshal_response(resp)
133
+ skip_auto_headers={"Content-Type"},
134
+ )
135
+ try:
136
+ return self._marshal_response(resp)
137
+ except BaseException:
138
+ resp.release()
139
+ raise
140
+
141
+ async def close(self) -> None:
142
+ """Close the underlying aiohttp session and its connection pool."""
143
+ if self._closed:
144
+ return
145
+ self._closed = True
146
+ await self._session.close()
147
+
148
+ async def __aenter__(self) -> Self:
149
+ if self._closed:
150
+ raise SmithyHTTPError("Cannot enter an HTTP client that has been closed.")
151
+ return self
152
+
153
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
154
+ await self.close()
118
155
 
119
156
  async def _prepare_body(self, body: StreamingBlob) -> AsyncBytesReader | None:
120
157
  """Convert a body for aiohttp, omitting seekable bodies with no data."""
@@ -129,8 +166,8 @@ class AIOHTTPClient(HTTPClient):
129
166
  await body.seek(position)
130
167
  return None if position == end else body
131
168
 
132
- def _serialize_uri_without_query(self, uri: URI) -> yarl.URL:
133
- """Serialize all parts of the URI up to and including the path."""
169
+ def _serialize_uri(self, uri: URI) -> yarl.URL:
170
+ """Serialize the URI, preserving the already-encoded query string as-is."""
134
171
  return yarl.URL.build(
135
172
  scheme=uri.scheme or "",
136
173
  host=uri.host,
@@ -138,10 +175,11 @@ class AIOHTTPClient(HTTPClient):
138
175
  user=uri.username,
139
176
  password=uri.password,
140
177
  path=uri.path or "",
178
+ query_string=uri.query or "",
141
179
  encoded=True,
142
180
  )
143
181
 
144
- async def _marshal_response(
182
+ def _marshal_response(
145
183
  self, aiohttp_resp: "aiohttp.ClientResponse"
146
184
  ) -> HTTPResponseInterface:
147
185
  """Convert a ``aiohttp.ClientResponse`` to a ``smithy_http.aio.HTTPResponse``"""
@@ -159,7 +197,7 @@ class AIOHTTPClient(HTTPClient):
159
197
  return HTTPResponse(
160
198
  status=aiohttp_resp.status,
161
199
  fields=headers,
162
- body=async_list([await aiohttp_resp.read()]),
200
+ body=_AIOHTTPStreamingBody(aiohttp_resp),
163
201
  reason=aiohttp_resp.reason,
164
202
  )
165
203
 
@@ -1,12 +1,13 @@
1
1
  # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  # pyright: reportMissingTypeStubs=false,reportUnknownMemberType=false
4
+ from asyncio import gather
4
5
  from collections.abc import AsyncGenerator, AsyncIterable
5
6
  from copy import deepcopy
6
7
  from dataclasses import dataclass
7
8
  from inspect import iscoroutinefunction
8
9
  from io import BytesIO
9
- from typing import TYPE_CHECKING, Any
10
+ from typing import TYPE_CHECKING, Any, Self
10
11
 
11
12
  from awscrt.exceptions import AwsCrtError
12
13
 
@@ -164,6 +165,7 @@ class AWSCRTHTTPClient(http_aio_interfaces.HTTPClient):
164
165
  self._tls_ctx = crt_io.ClientTlsContext(crt_io.TlsContextOptions())
165
166
  self._socket_options = crt_io.SocketOptions()
166
167
  self._connections: ConnectionPoolDict = {}
168
+ self._closed = False
167
169
 
168
170
  async def send(
169
171
  self,
@@ -176,6 +178,11 @@ class AWSCRTHTTPClient(http_aio_interfaces.HTTPClient):
176
178
  :param request: The request including destination URI, fields, payload.
177
179
  :param request_config: Configuration specific to this request.
178
180
  """
181
+ if self._closed:
182
+ raise SmithyHTTPError(
183
+ "Cannot send a request after the HTTP client has been closed."
184
+ )
185
+
179
186
  try:
180
187
  crt_request = self._marshal_request(request)
181
188
  connection = await self._get_connection(request.destination)
@@ -199,6 +206,26 @@ class AWSCRTHTTPClient(http_aio_interfaces.HTTPClient):
199
206
  raise _CRTTimeoutError(f"CRT {e.name}: {e.message}") from e
200
207
  raise
201
208
 
209
+ async def close(self) -> None:
210
+ """Close all pooled HTTP connections."""
211
+ if self._closed:
212
+ return
213
+ self._closed = True
214
+ connections = tuple(self._connections.values())
215
+ self._connections.clear()
216
+ await gather(
217
+ *(connection.close() for connection in connections),
218
+ return_exceptions=True,
219
+ )
220
+
221
+ async def __aenter__(self) -> Self:
222
+ if self._closed:
223
+ raise SmithyHTTPError("Cannot enter an HTTP client that has been closed.")
224
+ return self
225
+
226
+ async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
227
+ await self.close()
228
+
202
229
  async def _await_response(
203
230
  self, stream: "AIOHttpClientStreamUnified"
204
231
  ) -> AWSCRTHTTPResponse:
@@ -170,7 +170,9 @@ class HTTPRequestSerializer(SpecificShapeSerializer):
170
170
  payload = AsyncBytesReader(sync_payload)
171
171
 
172
172
  headers = binding_serializer.header_serializer.headers
173
- if content_type is not None:
173
+ if content_type is not None and not any(
174
+ name.lower() == "content-type" for name, _ in headers
175
+ ):
174
176
  headers.append(("content-type", content_type))
175
177
 
176
178
  if content_length is not None:
@@ -358,7 +360,9 @@ class HTTPResponseSerializer(SpecificShapeSerializer):
358
360
  payload = AsyncBytesReader(sync_payload)
359
361
 
360
362
  headers = binding_serializer.header_serializer.headers
361
- if content_type is not None:
363
+ if content_type is not None and not any(
364
+ name.lower() == "content-type" for name, _ in headers
365
+ ):
362
366
  headers.append(("content-type", content_type))
363
367
 
364
368
  if content_length is not None:
File without changes
File without changes
File without changes
File without changes