pyqwest 0.2.0__cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.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.
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ["AsyncPyqwestTransport", "PyqwestTransport"]
4
+
5
+ from ._transport import AsyncPyqwestTransport, PyqwestTransport
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ import httpx
8
+ from h2.errors import ErrorCodes
9
+ from h2.events import StreamReset
10
+
11
+ from pyqwest import (
12
+ Headers,
13
+ HTTPTransport,
14
+ Request,
15
+ Response,
16
+ StreamError,
17
+ StreamErrorCode,
18
+ SyncHTTPTransport,
19
+ SyncRequest,
20
+ SyncResponse,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import AsyncIterator, Iterator
25
+
26
+
27
+ class AsyncPyqwestTransport(httpx.AsyncBaseTransport):
28
+ """An HTTPX transport implementation that delegates to pyqwest.
29
+
30
+ This can be used with any existing code using httpx.AsyncClient, and will enable
31
+ use of bidirectional streaming and response trailers.
32
+ """
33
+
34
+ _transport: HTTPTransport
35
+
36
+ def __init__(self, transport: HTTPTransport) -> None:
37
+ """Creates a new AsyncPyQwestTransport.
38
+
39
+ Args:
40
+ transport: The pyqwest HTTPTransport to delegate requests to.
41
+ """
42
+ self._transport = transport
43
+
44
+ async def handle_async_request(
45
+ self, httpx_request: httpx.Request
46
+ ) -> httpx.Response:
47
+ request_headers = convert_headers(httpx_request.headers)
48
+ request_content = async_request_content(httpx_request.stream)
49
+ timeout = convert_timeout(httpx_request.extensions)
50
+
51
+ try:
52
+ response = await self._transport.execute(
53
+ Request(
54
+ httpx_request.method,
55
+ str(httpx_request.url),
56
+ headers=request_headers,
57
+ content=request_content,
58
+ timeout=timeout, # pyright: ignore[reportCallIssue]
59
+ )
60
+ )
61
+ except StreamError as e:
62
+ raise map_stream_error(e) from e
63
+
64
+ def get_trailers() -> httpx.Headers:
65
+ return httpx.Headers(tuple(response.trailers.items()))
66
+
67
+ return httpx.Response(
68
+ status_code=response.status,
69
+ headers=httpx.Headers(tuple(response.headers.items())),
70
+ stream=AsyncIteratorByteStream(response),
71
+ extensions={"get_trailers": get_trailers},
72
+ )
73
+
74
+
75
+ def async_request_content(
76
+ stream: httpx.AsyncByteStream | httpx.SyncByteStream | httpx.ByteStream,
77
+ ) -> bytes | AsyncIterator[bytes]:
78
+ match stream:
79
+ case httpx.ByteStream():
80
+ # Buffered bytes
81
+ return next(iter(stream))
82
+ case _:
83
+ return async_request_content_iter(stream)
84
+
85
+
86
+ async def async_request_content_iter(
87
+ stream: httpx.AsyncByteStream | httpx.SyncByteStream,
88
+ ) -> AsyncIterator[bytes]:
89
+ match stream:
90
+ case httpx.AsyncByteStream():
91
+ async with contextlib.aclosing(stream):
92
+ async for chunk in stream:
93
+ yield chunk
94
+ case httpx.SyncByteStream():
95
+ with contextlib.closing(stream):
96
+ stream_iter = iter(stream)
97
+ while True:
98
+ chunk = await asyncio.to_thread(next, stream_iter, None)
99
+ if chunk is None:
100
+ break
101
+ yield chunk
102
+
103
+
104
+ class AsyncIteratorByteStream(httpx.AsyncByteStream):
105
+ def __init__(self, response: Response) -> None:
106
+ self._response = response
107
+ self._is_stream_consumed = False
108
+
109
+ async def __aiter__(self) -> AsyncIterator[bytes]:
110
+ if self._is_stream_consumed:
111
+ raise httpx.StreamConsumed
112
+ self._is_stream_consumed = True
113
+ try:
114
+ async for chunk in self._response.content:
115
+ yield bytes(chunk)
116
+ except StreamError as e:
117
+ raise map_stream_error(e) from e
118
+
119
+ async def aclose(self) -> None:
120
+ await self._response.aclose()
121
+
122
+
123
+ class PyqwestTransport(httpx.BaseTransport):
124
+ """An HTTPX transport implementation that delegates to pyqwest.
125
+
126
+ This can be used with any existing code using httpx.Client, and will enable
127
+ use of bidirectional streaming and response trailers.
128
+ """
129
+
130
+ _transport: SyncHTTPTransport
131
+
132
+ def __init__(self, transport: SyncHTTPTransport) -> None:
133
+ """Creates a new PyQwestTransport.
134
+
135
+ Args:
136
+ transport: The pyqwest HTTPTransport to delegate requests to.
137
+ """
138
+ self._transport = transport
139
+
140
+ def handle_request(self, httpx_request: httpx.Request) -> httpx.Response:
141
+ request_headers = convert_headers(httpx_request.headers)
142
+ request_content = sync_request_content(httpx_request.stream)
143
+ timeout = convert_timeout(httpx_request.extensions)
144
+
145
+ try:
146
+ response = self._transport.execute_sync(
147
+ SyncRequest(
148
+ httpx_request.method,
149
+ str(httpx_request.url),
150
+ headers=request_headers,
151
+ content=request_content,
152
+ timeout=timeout, # pyright: ignore[reportCallIssue]
153
+ )
154
+ )
155
+ except StreamError as e:
156
+ raise map_stream_error(e) from e
157
+
158
+ def get_trailers() -> httpx.Headers:
159
+ return httpx.Headers(tuple(response.trailers.items()))
160
+
161
+ return httpx.Response(
162
+ status_code=response.status,
163
+ headers=httpx.Headers(tuple(response.headers.items())),
164
+ stream=IteratorByteStream(response),
165
+ extensions={"get_trailers": get_trailers},
166
+ )
167
+
168
+
169
+ def sync_request_content(
170
+ stream: httpx.AsyncByteStream | httpx.SyncByteStream | httpx.ByteStream,
171
+ ) -> bytes | Iterator[bytes]:
172
+ match stream:
173
+ case httpx.ByteStream():
174
+ # Buffered bytes
175
+ return next(iter(stream))
176
+ case _:
177
+ return sync_request_content_iter(stream)
178
+
179
+
180
+ def sync_request_content_iter(
181
+ stream: httpx.AsyncByteStream | httpx.SyncByteStream,
182
+ ) -> Iterator[bytes]:
183
+ match stream:
184
+ case httpx.AsyncByteStream():
185
+ msg = "unreachable"
186
+ raise TypeError(msg)
187
+ case httpx.SyncByteStream():
188
+ with contextlib.closing(stream):
189
+ yield from stream
190
+
191
+
192
+ class IteratorByteStream(httpx.SyncByteStream):
193
+ def __init__(self, response: SyncResponse) -> None:
194
+ self._response = response
195
+ self._is_stream_consumed = False
196
+
197
+ def __iter__(self) -> Iterator[bytes]:
198
+ if self._is_stream_consumed:
199
+ raise httpx.StreamConsumed
200
+ self._is_stream_consumed = True
201
+ try:
202
+ for chunk in self._response.content:
203
+ yield bytes(chunk)
204
+ except StreamError as e:
205
+ raise map_stream_error(e) from e
206
+
207
+ def close(self) -> None:
208
+ self._response.close()
209
+
210
+
211
+ # Headers that are managed by the transport and should not be forwarded.
212
+ TRANSPORT_HEADERS = {
213
+ "connection",
214
+ "keep-alive",
215
+ "proxy-connection",
216
+ "transfer-encoding",
217
+ "upgrade",
218
+ }
219
+
220
+
221
+ def convert_headers(headers: httpx.Headers) -> Headers:
222
+ return Headers(
223
+ (k, v) for k, v in headers.multi_items() if k.lower() not in TRANSPORT_HEADERS
224
+ )
225
+
226
+
227
+ def convert_timeout(extensions: dict) -> float | None:
228
+ httpx_timeout = cast("dict | None", extensions.get("timeout"))
229
+ if httpx_timeout is None:
230
+ return None
231
+ # reqwest does not support setting individual timeout settings
232
+ # per call, only an operation timeout, so we need to approximate
233
+ # that from the httpx timeout dict. Connect usually happens once
234
+ # and can be given a longer timeout - we assume the operation timeout
235
+ # is the max of read/write if present, or connect if not. We ignore
236
+ # pool for now
237
+ read_timeout = httpx_timeout.get("read", -1)
238
+ if read_timeout is None:
239
+ read_timeout = -1
240
+ write_timeout = httpx_timeout.get("write", -1)
241
+ if write_timeout is None:
242
+ write_timeout = -1
243
+ operation_timeout = max(read_timeout, write_timeout)
244
+ if operation_timeout != -1:
245
+ return operation_timeout
246
+ return httpx_timeout.get("connect")
247
+
248
+
249
+ def map_stream_error(e: StreamError) -> httpx.RemoteProtocolError:
250
+ match e.code:
251
+ case StreamErrorCode.NO_ERROR:
252
+ code = ErrorCodes.NO_ERROR
253
+ case StreamErrorCode.PROTOCOL_ERROR:
254
+ code = ErrorCodes.PROTOCOL_ERROR
255
+ case StreamErrorCode.INTERNAL_ERROR:
256
+ code = ErrorCodes.INTERNAL_ERROR
257
+ case StreamErrorCode.FLOW_CONTROL_ERROR:
258
+ code = ErrorCodes.FLOW_CONTROL_ERROR
259
+ case StreamErrorCode.SETTINGS_TIMEOUT:
260
+ code = ErrorCodes.SETTINGS_TIMEOUT
261
+ case StreamErrorCode.STREAM_CLOSED:
262
+ code = ErrorCodes.STREAM_CLOSED
263
+ case StreamErrorCode.FRAME_SIZE_ERROR:
264
+ code = ErrorCodes.FRAME_SIZE_ERROR
265
+ case StreamErrorCode.REFUSED_STREAM:
266
+ code = ErrorCodes.REFUSED_STREAM
267
+ case StreamErrorCode.CANCEL:
268
+ code = ErrorCodes.CANCEL
269
+ case StreamErrorCode.COMPRESSION_ERROR:
270
+ code = ErrorCodes.COMPRESSION_ERROR
271
+ case StreamErrorCode.CONNECT_ERROR:
272
+ code = ErrorCodes.CONNECT_ERROR
273
+ case StreamErrorCode.ENHANCE_YOUR_CALM:
274
+ code = ErrorCodes.ENHANCE_YOUR_CALM
275
+ case StreamErrorCode.INADEQUATE_SECURITY:
276
+ code = ErrorCodes.INADEQUATE_SECURITY
277
+ case StreamErrorCode.HTTP_1_1_REQUIRED:
278
+ code = ErrorCodes.HTTP_1_1_REQUIRED
279
+ case _:
280
+ code = ErrorCodes.INTERNAL_ERROR
281
+ return httpx.RemoteProtocolError(str(StreamReset(stream_id=-1, error_code=code)))
pyqwest/py.typed ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ["ASGITransport", "WSGITransport"]
4
+
5
+ from ._asgi import ASGITransport
6
+ from ._wsgi import WSGITransport
@@ -0,0 +1,366 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import math
6
+ from collections.abc import AsyncIterator
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Any
9
+ from urllib.parse import unquote, urlparse
10
+
11
+ from pyqwest import (
12
+ Headers,
13
+ HTTPVersion,
14
+ ReadError,
15
+ Request,
16
+ Response,
17
+ Transport,
18
+ WriteError,
19
+ )
20
+
21
+ from ._asgi_compatibility import guarantee_single_callable
22
+
23
+ if TYPE_CHECKING:
24
+ from types import TracebackType
25
+
26
+ from asgiref.typing import (
27
+ ASGI3Application,
28
+ ASGIApplication,
29
+ ASGIReceiveEvent,
30
+ ASGISendEvent,
31
+ ASGIVersions,
32
+ HTTPScope,
33
+ LifespanScope,
34
+ LifespanShutdownEvent,
35
+ LifespanStartupEvent,
36
+ )
37
+
38
+ _asgi: ASGIVersions = {"version": "3.0", "spec_version": "2.5"}
39
+ _extensions = {"http.response.trailers": {}}
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Lifespan:
44
+ task: asyncio.Task[None]
45
+ receive_queue: asyncio.Queue[LifespanStartupEvent | LifespanShutdownEvent]
46
+ send_queue: asyncio.Queue[ASGISendEvent | Exception]
47
+
48
+
49
+ class ASGITransport(Transport):
50
+ """Transport implementation that directly invokes an ASGI application. Useful for testing.
51
+
52
+ The ASGI transport supports lifespan - to use it, make sure to use the transport as an
53
+ asynchronous context manager. Lifespan startup will be run on entering and shutdown when
54
+ exiting.
55
+ """
56
+
57
+ _app: ASGI3Application
58
+ _http_version: HTTPVersion
59
+ _client: tuple[str, int]
60
+ _state: dict[str, Any]
61
+ _lifespan: Lifespan | None
62
+
63
+ def __init__(
64
+ self,
65
+ app: ASGIApplication,
66
+ http_version: HTTPVersion = HTTPVersion.HTTP2,
67
+ client: tuple[str, int] = ("127.0.0.1", 111),
68
+ ) -> None:
69
+ """Creates a new ASGI transport.
70
+
71
+ Args:
72
+ app: The ASGI application to invoke.
73
+ http_version: The HTTP version to mimic for requests. Note, semantics such as lack of
74
+ bidirectional streaming for HTTP/1 are not enforced.
75
+ client: The (host, port) tuple to use for the client address in the ASGI scope.
76
+ """
77
+ self._app = guarantee_single_callable(app)
78
+ self._http_version = http_version
79
+ self._client = client
80
+ self._state = {}
81
+ self._lifespan = None
82
+
83
+ async def execute(self, request: Request) -> Response:
84
+ parsed_url = urlparse(request.url)
85
+ raw_path = parsed_url.path or "/"
86
+ path = unquote(raw_path)
87
+ match self._http_version:
88
+ case HTTPVersion.HTTP1:
89
+ http_version = "1.1"
90
+ case HTTPVersion.HTTP2:
91
+ http_version = "2"
92
+ case HTTPVersion.HTTP3:
93
+ http_version = "3"
94
+ case _:
95
+ http_version = "1.1"
96
+ scope: HTTPScope = {
97
+ "type": "http",
98
+ "asgi": _asgi,
99
+ "http_version": http_version,
100
+ "method": request.method,
101
+ "scheme": parsed_url.scheme,
102
+ "path": path,
103
+ "raw_path": raw_path.encode(),
104
+ "query_string": parsed_url.query.encode(),
105
+ "headers": [
106
+ (k.lower().encode("utf-8"), v.encode("utf-8"))
107
+ for k, v in request.headers.items()
108
+ ],
109
+ "server": (
110
+ parsed_url.hostname or "",
111
+ parsed_url.port or (443 if parsed_url.scheme == "https" else 80),
112
+ ),
113
+ "client": self._client,
114
+ "extensions": _extensions,
115
+ "state": self._state,
116
+ "root_path": "",
117
+ }
118
+
119
+ receive_queue: asyncio.Queue[bytes | Exception | None] = asyncio.Queue(1)
120
+
121
+ async def read_request_content() -> None:
122
+ try:
123
+ async for chunk in request.content:
124
+ if not isinstance(chunk, bytes):
125
+ msg = "Request not bytes object"
126
+ raise WriteError(msg) # noqa: TRY301
127
+ await receive_queue.put(chunk)
128
+ await receive_queue.put(None)
129
+ except Exception as e:
130
+ await receive_queue.put(e)
131
+ finally:
132
+ try:
133
+ aclose = request.content.aclose # pyright: ignore[reportAttributeAccessIssue]
134
+ except AttributeError:
135
+ pass
136
+ else:
137
+ await aclose()
138
+
139
+ # Need a separate task to read the request body to allow
140
+ # cancelling when response closes.
141
+ request_task = asyncio.create_task(read_request_content())
142
+
143
+ async def receive() -> ASGIReceiveEvent:
144
+ chunk = await receive_queue.get()
145
+ if chunk is None:
146
+ return {"type": "http.request", "body": b"", "more_body": False}
147
+ if isinstance(chunk, Exception):
148
+ if self._http_version != HTTPVersion.HTTP2:
149
+ msg = f"Request failed: {chunk}"
150
+ else:
151
+ # With HTTP/2, reqwest seems to squash the original error message.
152
+ msg = "Request failed: stream error sent by user"
153
+ raise WriteError(msg) from chunk
154
+ if isinstance(chunk, BaseException):
155
+ raise chunk
156
+ return {"type": "http.request", "body": chunk, "more_body": True}
157
+
158
+ send_queue: asyncio.Queue[ASGISendEvent | Exception] = asyncio.Queue()
159
+
160
+ async def send(message: ASGISendEvent) -> None:
161
+ await send_queue.put(message)
162
+
163
+ timeout: float | None = request._timeout # pyright: ignore[reportAttributeAccessIssue] # noqa: SLF001
164
+ if timeout is not None and (timeout < 0 or not math.isfinite(timeout)):
165
+ msg = "Timeout must be non-negative"
166
+ raise ValueError(msg)
167
+
168
+ async def run_app() -> None:
169
+ try:
170
+ await asyncio.wait_for(self._app(scope, receive, send), timeout=timeout)
171
+ except asyncio.TimeoutError as e:
172
+ send_queue.put_nowait(TimeoutError(str(e)))
173
+ except Exception as e:
174
+ send_queue.put_nowait(e)
175
+
176
+ app_task = asyncio.create_task(run_app())
177
+ message = await send_queue.get()
178
+ if isinstance(message, Exception):
179
+ await app_task
180
+ if isinstance(message, TimeoutError):
181
+ raise message
182
+ return Response(
183
+ status=500,
184
+ http_version=self._http_version,
185
+ headers=Headers((("content-type", "text/plain"),)),
186
+ content=str(message).encode(),
187
+ )
188
+
189
+ assert message["type"] == "http.response.start" # noqa: S101
190
+ status = message["status"]
191
+ headers = Headers(
192
+ (
193
+ (k.decode("utf-8"), v.decode("utf-8"))
194
+ for k, v in message.get("headers", [])
195
+ )
196
+ )
197
+ trailers = (
198
+ Headers()
199
+ if self._http_version == HTTPVersion.HTTP2
200
+ and request.headers.get("te") == "trailers"
201
+ else None
202
+ )
203
+ response_content = ResponseContent(
204
+ send_queue,
205
+ request_task,
206
+ trailers,
207
+ app_task,
208
+ read_trailers=message.get("trailers", False),
209
+ )
210
+ return Response(
211
+ status=status,
212
+ http_version=self._http_version,
213
+ headers=headers,
214
+ content=response_content,
215
+ trailers=trailers,
216
+ )
217
+
218
+ async def __aenter__(self) -> ASGITransport:
219
+ await self.run_lifespan()
220
+ return self
221
+
222
+ async def __aexit__(
223
+ self,
224
+ _exc_type: type[BaseException] | None,
225
+ _exc_value: BaseException | None,
226
+ _traceback: TracebackType | None,
227
+ ) -> None:
228
+ await self.close()
229
+
230
+ async def run_lifespan(self) -> None:
231
+ scope: LifespanScope = {"type": "lifespan", "asgi": _asgi, "state": self._state}
232
+
233
+ receive_queue: asyncio.Queue[LifespanStartupEvent | LifespanShutdownEvent] = (
234
+ asyncio.Queue()
235
+ )
236
+
237
+ async def receive() -> LifespanStartupEvent | LifespanShutdownEvent:
238
+ return await receive_queue.get()
239
+
240
+ send_queue: asyncio.Queue[ASGISendEvent | Exception] = asyncio.Queue()
241
+
242
+ async def send(message: ASGISendEvent) -> None:
243
+ await send_queue.put(message)
244
+
245
+ async def run_app() -> None:
246
+ try:
247
+ await self._app(scope, receive, send)
248
+ except Exception as e:
249
+ send_queue.put_nowait(e)
250
+
251
+ task = asyncio.create_task(run_app())
252
+
253
+ receive_queue.put_nowait({"type": "lifespan.startup"})
254
+ message = await send_queue.get()
255
+ if isinstance(message, Exception):
256
+ # Lifespan not supported
257
+ await task
258
+ return
259
+
260
+ self._lifespan = Lifespan(
261
+ task=task, receive_queue=receive_queue, send_queue=send_queue
262
+ )
263
+ match message["type"]:
264
+ case "lifespan.startup.complete":
265
+ return
266
+ case "lifespan.startup.failed":
267
+ msg = "ASGI application failed to start up"
268
+ raise RuntimeError(msg)
269
+
270
+ async def close(self) -> None:
271
+ if self._lifespan is None:
272
+ return
273
+
274
+ await self._lifespan.receive_queue.put({"type": "lifespan.shutdown"})
275
+ message = await self._lifespan.send_queue.get()
276
+ await self._lifespan.task
277
+ if isinstance(message, Exception):
278
+ raise message
279
+ match message["type"]:
280
+ case "lifespan.shutdown.complete":
281
+ return
282
+ case "lifespan.shutdown.failed":
283
+ msg = "ASGI application failed to shut down cleanly"
284
+ raise RuntimeError(msg)
285
+
286
+
287
+ class CancelResponse(Exception):
288
+ pass
289
+
290
+
291
+ class ResponseContent(AsyncIterator[bytes]):
292
+ def __init__(
293
+ self,
294
+ send_queue: asyncio.Queue[ASGISendEvent | Exception],
295
+ request_task: asyncio.Task[None],
296
+ trailers: Headers | None,
297
+ task: asyncio.Task[None],
298
+ *,
299
+ read_trailers: bool,
300
+ ) -> None:
301
+ self._send_queue = send_queue
302
+ self._request_task = request_task
303
+ self._trailers = trailers
304
+ self._task = task
305
+ self._read_trailers = read_trailers
306
+
307
+ self._read_pending = False
308
+ self._closed = False
309
+
310
+ def __aiter__(self) -> AsyncIterator[bytes]:
311
+ return self
312
+
313
+ async def __anext__(self) -> bytes:
314
+ if self._closed:
315
+ raise StopAsyncIteration
316
+ err: Exception | None = None
317
+ body: bytes | None = None
318
+ while True:
319
+ self._read_pending = True
320
+ try:
321
+ message = await self._send_queue.get()
322
+ finally:
323
+ self._read_pending = False
324
+ if isinstance(message, Exception):
325
+ match message:
326
+ case CancelResponse():
327
+ err = StopAsyncIteration()
328
+ break
329
+ case WriteError() | TimeoutError():
330
+ err = message
331
+ break
332
+ case ReadError():
333
+ raise message
334
+ case Exception():
335
+ msg = "Error reading response body"
336
+ raise ReadError(msg) from message
337
+ match message["type"]:
338
+ case "http.response.body":
339
+ if not message.get("more_body", False) and not self._read_trailers:
340
+ await self._cleanup()
341
+ if (body := message.get("body", b"")) or self._closed:
342
+ return body
343
+ case "http.response.trailers":
344
+ if self._trailers is not None:
345
+ for k, v in message.get("headers", []):
346
+ self._trailers.add(k.decode("utf-8"), v.decode("utf-8"))
347
+ if not message.get("more_trailers", False):
348
+ break
349
+ await self._cleanup()
350
+ if err:
351
+ raise err
352
+ raise StopAsyncIteration
353
+
354
+ async def aclose(self) -> None:
355
+ if self._closed:
356
+ return
357
+ self._closed = True
358
+ self._send_queue.put_nowait(ReadError("Response body read cancelled"))
359
+ await self._cleanup()
360
+
361
+ async def _cleanup(self) -> None:
362
+ self._closed = True
363
+ self._request_task.cancel()
364
+ with contextlib.suppress(BaseException):
365
+ await self._request_task
366
+ await self._task