pyreqwest 0.5.0__cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.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.
Potentially problematic release.
This version of pyreqwest might be problematic. Click here for more details.
- pyreqwest/__init__.py +3 -0
- pyreqwest/__init__.pyi +1 -0
- pyreqwest/_pyreqwest.cpython-312-i386-linux-gnu.so +0 -0
- pyreqwest/bytes/__init__.py +16 -0
- pyreqwest/bytes/__init__.pyi +106 -0
- pyreqwest/client/__init__.py +21 -0
- pyreqwest/client/__init__.pyi +341 -0
- pyreqwest/client/types.py +54 -0
- pyreqwest/cookie/__init__.py +5 -0
- pyreqwest/cookie/__init__.pyi +174 -0
- pyreqwest/exceptions/__init__.py +193 -0
- pyreqwest/http/__init__.py +19 -0
- pyreqwest/http/__init__.pyi +344 -0
- pyreqwest/middleware/__init__.py +5 -0
- pyreqwest/middleware/__init__.pyi +12 -0
- pyreqwest/middleware/asgi/__init__.py +5 -0
- pyreqwest/middleware/asgi/asgi.py +168 -0
- pyreqwest/middleware/types.py +26 -0
- pyreqwest/multipart/__init__.py +5 -0
- pyreqwest/multipart/__init__.pyi +75 -0
- pyreqwest/proxy/__init__.py +5 -0
- pyreqwest/proxy/__init__.pyi +47 -0
- pyreqwest/py.typed +0 -0
- pyreqwest/pytest_plugin/__init__.py +8 -0
- pyreqwest/pytest_plugin/internal/__init__.py +0 -0
- pyreqwest/pytest_plugin/internal/assert_eq.py +6 -0
- pyreqwest/pytest_plugin/internal/assert_message.py +123 -0
- pyreqwest/pytest_plugin/internal/matcher.py +34 -0
- pyreqwest/pytest_plugin/internal/plugin.py +15 -0
- pyreqwest/pytest_plugin/mock.py +493 -0
- pyreqwest/pytest_plugin/types.py +26 -0
- pyreqwest/request/__init__.py +25 -0
- pyreqwest/request/__init__.pyi +200 -0
- pyreqwest/response/__init__.py +19 -0
- pyreqwest/response/__init__.pyi +157 -0
- pyreqwest/types.py +12 -0
- pyreqwest-0.5.0.dist-info/METADATA +106 -0
- pyreqwest-0.5.0.dist-info/RECORD +41 -0
- pyreqwest-0.5.0.dist-info/WHEEL +4 -0
- pyreqwest-0.5.0.dist-info/entry_points.txt +2 -0
- pyreqwest-0.5.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
from collections.abc import ItemsView, Iterator, KeysView, MutableMapping, Sequence, ValuesView
|
|
2
|
+
from typing import Any, Self, TypeVar, overload
|
|
3
|
+
|
|
4
|
+
from pyreqwest.types import HeadersType, QueryParams
|
|
5
|
+
|
|
6
|
+
_T = TypeVar("_T")
|
|
7
|
+
|
|
8
|
+
class Url:
|
|
9
|
+
"""Immutable parsed URL. Python wrapper around the internal Rust url::Url type.
|
|
10
|
+
|
|
11
|
+
See also Rust [docs](https://docs.rs/url/latest/url/struct.Url.html) for more details.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, url: str) -> None:
|
|
15
|
+
"""Parse an absolute URL from a string."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def parse(url: str) -> "Url":
|
|
19
|
+
"""Parse an absolute URL from a string. Same as Url(url)."""
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def parse_with_params(url: str, params: QueryParams) -> "Url":
|
|
23
|
+
"""Parse an absolute URL from a string and add params to its query string. Existing params are not removed."""
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def is_valid(value: str) -> bool:
|
|
27
|
+
"""Check is given value a valid absolute URL."""
|
|
28
|
+
|
|
29
|
+
def join(self, join_input: str) -> Self:
|
|
30
|
+
"""Parse a string as a URL, with this URL as the base URL. The inverse of this is make_relative.
|
|
31
|
+
|
|
32
|
+
Notes:
|
|
33
|
+
- A trailing slash is significant. Without it, the last path component is considered to be a “file” name to be
|
|
34
|
+
removed to get at the “directory” that is used as the base.
|
|
35
|
+
- A scheme relative special URL as input replaces everything in the base URL after the scheme.
|
|
36
|
+
- An absolute URL (with a scheme) as input replaces the whole base URL (even the scheme).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def make_relative(self, base: Self | str) -> str | None:
|
|
40
|
+
"""Creates a relative URL if possible, with this URL as the base URL. This is the inverse of join."""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def origin_ascii(self) -> str:
|
|
44
|
+
"""Return the origin of this URL ASCII-serialized."""
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def origin_unicode(self) -> str:
|
|
48
|
+
"""Return the origin of this URL Unicode-serialized."""
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def scheme(self) -> str:
|
|
52
|
+
"""Return the scheme of this URL, lower-cased, as an ASCII string without the ':' delimiter."""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_special(self) -> bool:
|
|
56
|
+
"""Whether the scheme is a WHATWG "special" scheme (http, https, ws, wss, ftp, file)."""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def has_authority(self) -> bool:
|
|
60
|
+
"""Return whether the URL has an 'authority', which can contain a username, password, host, and port number.
|
|
61
|
+
|
|
62
|
+
URLs that do not are either path-only like unix:/run/foo.socket or cannot-be-a-base like data:text/plain,Stuff.
|
|
63
|
+
See also the `authority` method.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def authority(self) -> str:
|
|
68
|
+
"""Return the authority of this URL as an ASCII string.
|
|
69
|
+
|
|
70
|
+
Non-ASCII domains are punycode-encoded per IDNA if this is the host of a special URL, or percent encoded for
|
|
71
|
+
non-special URLs. IPv6 addresses are given between [ and ] brackets. Ports are omitted if they match the well
|
|
72
|
+
known port of a special URL. Username and password are percent-encoded.
|
|
73
|
+
See also the `has_authority` method.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def cannot_be_a_base(self) -> bool:
|
|
78
|
+
"""Return whether this URL is a cannot-be-a-base URL, meaning that parsing a relative URL string with this URL
|
|
79
|
+
as the base will return an error.
|
|
80
|
+
|
|
81
|
+
This is the case if the scheme and : delimiter are not followed by a / slash, as is typically the case of data:
|
|
82
|
+
and mailto: URLs.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def username(self) -> str:
|
|
87
|
+
"""Return the username for this URL (typically the empty string) as a percent-encoded ASCII string."""
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def password(self) -> str | None:
|
|
91
|
+
"""Return the password for this URL, if any, as a percent-encoded ASCII string."""
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def has_host(self) -> bool:
|
|
95
|
+
"""Equivalent to bool(url.host_str)."""
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def host_str(self) -> str | None:
|
|
99
|
+
"""Return the string representation of the host (domain or IP address) for this URL, if any.
|
|
100
|
+
|
|
101
|
+
Non-ASCII domains are punycode-encoded per IDNA if this is the host of a special URL, or percent encoded for
|
|
102
|
+
non-special URLs. IPv6 addresses are given between [ and ] brackets.
|
|
103
|
+
Cannot-be-a-base URLs (typical of data: and mailto:) and some file: URLs don't have a host.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def domain(self) -> str | None:
|
|
108
|
+
"""If this URL has a host and it is a domain name (not an IP address), return it. Non-ASCII domains are
|
|
109
|
+
punycode-encoded per IDNA if this is the host of a special URL, or percent encoded for non-special URLs.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def port(self) -> int | None:
|
|
114
|
+
"""Return the port number for this URL, if any."""
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def port_or_known_default(self) -> int | None:
|
|
118
|
+
"""Return the port number for this URL, or the default port number if it is known.
|
|
119
|
+
|
|
120
|
+
This method only knows the default port number of the http, https, ws, wss and ftp schemes.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def path(self) -> str:
|
|
125
|
+
"""Return the path for this URL, as a percent-encoded ASCII string. For cannot-be-a-base URLs, this is an
|
|
126
|
+
arbitrary string that doesn't start with '/'. For other URLs, this starts with a '/' slash and continues with
|
|
127
|
+
slash-separated path segments.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def path_segments(self) -> list[str] | None:
|
|
132
|
+
"""Unless this URL is cannot-be-a-base, return a list of '/' slash-separated path segments, each as a
|
|
133
|
+
percent-encoded ASCII string. Return None for cannot-be-a-base URLs. When list is returned, it always contains
|
|
134
|
+
at least one string (which may be empty).
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def query_string(self) -> str | None:
|
|
139
|
+
"""Return this URL's query string, if any, as a percent-encoded ASCII string."""
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def query_pairs(self) -> list[tuple[str, str]]:
|
|
143
|
+
"""Parse the URL's query string, if any, as urlencoded and return list of (key, value) pairs."""
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def query_dict_multi_value(self) -> dict[str, str | list[str]]:
|
|
147
|
+
"""Parse the URL's query string, if any, as urlencoded and return dict where repeated keys become a list
|
|
148
|
+
preserving order.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def fragment(self) -> str | None:
|
|
153
|
+
"""Return this URL's fragment identifier, if any. A fragment is the part of the URL after the # symbol."""
|
|
154
|
+
|
|
155
|
+
def with_query(self, query: QueryParams | None) -> Self:
|
|
156
|
+
"""Replace the entire query with provided params (None removes query)."""
|
|
157
|
+
|
|
158
|
+
def extend_query(self, query: QueryParams) -> Self:
|
|
159
|
+
"""Append additional key/value pairs to existing query keeping original order."""
|
|
160
|
+
|
|
161
|
+
def with_query_string(self, query: str | None) -> Self:
|
|
162
|
+
"""Replace query using a preformatted string (no leading '?'). None removes it."""
|
|
163
|
+
|
|
164
|
+
def with_path(self, path: str) -> Self:
|
|
165
|
+
"""Return a copy with a new path. Accepts with/without leading '/'. Empty path means '/'."""
|
|
166
|
+
|
|
167
|
+
def with_path_segments(self, segments: list[str]) -> Self:
|
|
168
|
+
"""Append each segment from the given list at the end of this URL's path.
|
|
169
|
+
Each segment is percent-encoded, except that % and / characters are also encoded (to %25 and %2F).
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def with_port(self, port: int | None) -> Self:
|
|
173
|
+
"""Change this URL's port number. None removes explicit port."""
|
|
174
|
+
|
|
175
|
+
def with_host(self, host: str | None) -> Self:
|
|
176
|
+
"""Change this URL's host.
|
|
177
|
+
Removing the host (calling this with None) will also remove any username, password, and port number.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def with_ip_host(self, addr: str) -> Self:
|
|
181
|
+
"""Change this URL's host to the given IP address."""
|
|
182
|
+
|
|
183
|
+
def with_username(self, username: str) -> Self:
|
|
184
|
+
"""Change this URL's username."""
|
|
185
|
+
|
|
186
|
+
def with_password(self, password: str | None) -> Self:
|
|
187
|
+
"""Change this URL's password."""
|
|
188
|
+
|
|
189
|
+
def with_scheme(self, scheme: str) -> Self:
|
|
190
|
+
"""Change this URL's scheme."""
|
|
191
|
+
|
|
192
|
+
def with_fragment(self, fragment: str | None) -> Self:
|
|
193
|
+
"""Change this URL's fragment identifier."""
|
|
194
|
+
|
|
195
|
+
def __truediv__(self, join_input: str) -> Self:
|
|
196
|
+
"""Path join shorthand: url / 'segment' == url.join('segment')."""
|
|
197
|
+
|
|
198
|
+
def __contains__(self, item: Any) -> bool: ...
|
|
199
|
+
def __copy__(self) -> Self: ...
|
|
200
|
+
def __hash__(self) -> int: ...
|
|
201
|
+
@overload
|
|
202
|
+
def __getitem__(self, index: int) -> str: ...
|
|
203
|
+
@overload
|
|
204
|
+
def __getitem__(self, index: slice) -> Sequence[str]: ...
|
|
205
|
+
def __len__(self) -> int: ...
|
|
206
|
+
def __eq__(self, other: object) -> bool: ...
|
|
207
|
+
def __lt__(self, other: object) -> bool: ...
|
|
208
|
+
def __le__(self, other: object) -> bool: ...
|
|
209
|
+
|
|
210
|
+
class Mime:
|
|
211
|
+
"""Parsed media (MIME) type. Lightweight Python wrapper around the internal Rust mime::Mime type.
|
|
212
|
+
|
|
213
|
+
See also Rust [docs](https://docs.rs/mime/latest/mime/struct.Mime.html) for more details.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def parse(mime: str) -> "Mime":
|
|
218
|
+
"""Parse a MIME string into a `Mime`."""
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def type_(self) -> str:
|
|
222
|
+
"""Lowercased top-level type (before '/')."""
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def subtype(self) -> str:
|
|
226
|
+
"""Lowercased subtype (after '/'), excluding any +suffix."""
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def suffix(self) -> str | None:
|
|
230
|
+
"""Structured syntax suffix (text after '+') or None."""
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def parameters(self) -> list[tuple[str, str]]:
|
|
234
|
+
"""List of (name, value) parameter pairs in original order. Names are lowercase; duplicates kept."""
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def essence_str(self) -> str:
|
|
238
|
+
"""type/subtype(+suffix) without parameters (RFC 6838 essence)."""
|
|
239
|
+
|
|
240
|
+
def get_param(self, name: str) -> str | None:
|
|
241
|
+
"""Return first parameter value whose name (case-insensitive) matches, else None."""
|
|
242
|
+
|
|
243
|
+
def __contains__(self, item: Any) -> bool: ...
|
|
244
|
+
def __copy__(self) -> Self: ...
|
|
245
|
+
def __hash__(self) -> int: ...
|
|
246
|
+
@overload
|
|
247
|
+
def __getitem__(self, index: int) -> str: ...
|
|
248
|
+
@overload
|
|
249
|
+
def __getitem__(self, index: slice) -> Sequence[str]: ...
|
|
250
|
+
def __len__(self) -> int: ...
|
|
251
|
+
def __eq__(self, other: object) -> bool: ...
|
|
252
|
+
def __lt__(self, other: object) -> bool: ...
|
|
253
|
+
def __le__(self, other: object) -> bool: ...
|
|
254
|
+
|
|
255
|
+
class HeaderMapItemsView(ItemsView[str, str]):
|
|
256
|
+
"""Live view of (key,value) pairs in insertion order."""
|
|
257
|
+
def __eq__(self, other: object) -> bool: ...
|
|
258
|
+
|
|
259
|
+
class HeaderMapKeysView(KeysView[str]):
|
|
260
|
+
"""Live view of header keys (one per stored value)."""
|
|
261
|
+
def __eq__(self, other: object) -> bool: ...
|
|
262
|
+
|
|
263
|
+
class HeaderMapValuesView(ValuesView[str]):
|
|
264
|
+
"""Live view of values in insertion order."""
|
|
265
|
+
def __eq__(self, other: object) -> bool: ...
|
|
266
|
+
|
|
267
|
+
class HeaderMap(MutableMapping[str, str]):
|
|
268
|
+
"""Case-insensitive multi-value HTTP header map."""
|
|
269
|
+
|
|
270
|
+
def __init__(self, other: HeadersType | None = None) -> None:
|
|
271
|
+
"""Init from: Sequence of (key,value) pairs or Mapping."""
|
|
272
|
+
|
|
273
|
+
def __len__(self) -> int:
|
|
274
|
+
"""Number of items in the map."""
|
|
275
|
+
|
|
276
|
+
def __iter__(self) -> Iterator[str]:
|
|
277
|
+
"""Iterate over header keys (one entry per stored value, so duplicates repeat)."""
|
|
278
|
+
|
|
279
|
+
def __getitem__(self, key: str, /) -> str:
|
|
280
|
+
"""First value for key."""
|
|
281
|
+
|
|
282
|
+
def __setitem__(self, key: str, value: str, /) -> None:
|
|
283
|
+
"""Set single value for key; replaces all existing values for that key."""
|
|
284
|
+
|
|
285
|
+
def __delitem__(self, key: str, /) -> None:
|
|
286
|
+
"""Delete all values for key."""
|
|
287
|
+
|
|
288
|
+
def __eq__(self, other: object) -> bool:
|
|
289
|
+
"""Equality: per-key value sequence must match; inter-key ordering ignored.
|
|
290
|
+
Supports mappings and key/value pair iterables.
|
|
291
|
+
"""
|
|
292
|
+
|
|
293
|
+
def items(self) -> HeaderMapItemsView:
|
|
294
|
+
"""Live view of (key,value) pairs in insertion order."""
|
|
295
|
+
|
|
296
|
+
def keys(self) -> HeaderMapKeysView:
|
|
297
|
+
"""Live view of header keys (one per stored value)."""
|
|
298
|
+
|
|
299
|
+
def values(self) -> HeaderMapValuesView:
|
|
300
|
+
"""Live view of values in insertion order."""
|
|
301
|
+
|
|
302
|
+
def keys_len(self) -> int:
|
|
303
|
+
"""Count of distinct header keys."""
|
|
304
|
+
|
|
305
|
+
def getall(self, key: str) -> list[str]:
|
|
306
|
+
"""All values for key. Empty if key does not exist."""
|
|
307
|
+
|
|
308
|
+
def insert(self, key: str, value: str, *, is_sensitive: bool = False) -> list[str]:
|
|
309
|
+
"""Replace all existing values for key with value. Returns list of removed values (may be empty)."""
|
|
310
|
+
|
|
311
|
+
def append(self, key: str, value: str, *, is_sensitive: bool = False) -> bool:
|
|
312
|
+
"""Add value for a key. If the map did have this key present, the new value is pushed to the end of the list of
|
|
313
|
+
values currently associated with the key. Return True if key already existed, else False.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
def extend(self, other: HeadersType) -> None:
|
|
317
|
+
"""Append pairs from mapping / iterable."""
|
|
318
|
+
|
|
319
|
+
@overload
|
|
320
|
+
def popall(self, key: str) -> list[str]:
|
|
321
|
+
"""Remove all values for key and return list. With default returns it instead of raising KeyError."""
|
|
322
|
+
@overload
|
|
323
|
+
def popall(self, key: str, /, default: _T) -> list[str] | _T:
|
|
324
|
+
"""Remove all values for key and return list. With default returns it instead of raising KeyError."""
|
|
325
|
+
|
|
326
|
+
def dict_multi_value(self) -> dict[str, str | list[str]]:
|
|
327
|
+
"""Dict: single-value headers -> str; multi-value -> list[str]."""
|
|
328
|
+
|
|
329
|
+
def copy(self) -> Self:
|
|
330
|
+
"""Copy the map."""
|
|
331
|
+
|
|
332
|
+
def __copy__(self) -> Self: ...
|
|
333
|
+
@overload
|
|
334
|
+
def pop(self, key: str) -> str:
|
|
335
|
+
"""Remove & return FIRST value for key. With default returns default instead of KeyError."""
|
|
336
|
+
@overload
|
|
337
|
+
def pop(self, key: str, default: _T = ...) -> str | _T:
|
|
338
|
+
"""Remove & return FIRST value for key. With default returns default instead of KeyError."""
|
|
339
|
+
|
|
340
|
+
def popitem(self) -> tuple[str, str]:
|
|
341
|
+
"""Remove & return an arbitrary (key, FIRST value) respecting value ordering; KeyError if empty."""
|
|
342
|
+
|
|
343
|
+
def clear(self) -> None:
|
|
344
|
+
"""Empty the map."""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from pyreqwest.request import Request
|
|
2
|
+
from pyreqwest.response import Response, SyncResponse
|
|
3
|
+
|
|
4
|
+
class Next:
|
|
5
|
+
"""Next middleware caller in the chain."""
|
|
6
|
+
async def run(self, request: Request) -> Response:
|
|
7
|
+
"""Call the next middleware in the chain with the given request."""
|
|
8
|
+
|
|
9
|
+
class SyncNext:
|
|
10
|
+
"""Next middleware caller in the chain."""
|
|
11
|
+
def run(self, request: Request) -> SyncResponse:
|
|
12
|
+
"""Call the next middleware in the chain with the given request."""
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""ASGI middleware."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, MutableMapping
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import Any, Self
|
|
7
|
+
from urllib.parse import unquote
|
|
8
|
+
|
|
9
|
+
from pyreqwest.middleware import Next
|
|
10
|
+
from pyreqwest.request import Request
|
|
11
|
+
from pyreqwest.response import Response, ResponseBuilder
|
|
12
|
+
|
|
13
|
+
Scope = MutableMapping[str, Any]
|
|
14
|
+
Message = MutableMapping[str, Any]
|
|
15
|
+
Receive = Callable[[], Awaitable[Message]]
|
|
16
|
+
Send = Callable[[Message], Awaitable[None]]
|
|
17
|
+
ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ASGITestMiddleware:
|
|
21
|
+
"""Test client that routes requests into an ASGI application."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
app: ASGIApp,
|
|
26
|
+
*,
|
|
27
|
+
timeout: timedelta | None = None,
|
|
28
|
+
scope_update: Callable[[dict[str, Any], Request], Awaitable[None]] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Initialize the ASGI test client.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
app: ASGI application callable
|
|
34
|
+
timeout: Timeout for ASGI operations (default: 5 seconds)
|
|
35
|
+
scope_update: Optional coroutine to modify the ASGI scope per request
|
|
36
|
+
"""
|
|
37
|
+
self._app = app
|
|
38
|
+
self._scope_update = scope_update
|
|
39
|
+
self._timeout = timeout or timedelta(seconds=5)
|
|
40
|
+
self._lifespan_input_queue: asyncio.Queue[MutableMapping[str, Any]] = asyncio.Queue()
|
|
41
|
+
self._lifespan_output_queue: asyncio.Queue[MutableMapping[str, Any]] = asyncio.Queue()
|
|
42
|
+
self._lifespan_task: asyncio.Task[None] | None = None
|
|
43
|
+
self._state: dict[str, Any] = {}
|
|
44
|
+
|
|
45
|
+
async def __aenter__(self) -> Self:
|
|
46
|
+
"""Start up the ASGI application."""
|
|
47
|
+
|
|
48
|
+
async def wrapped_lifespan() -> None:
|
|
49
|
+
await self._app(
|
|
50
|
+
{"type": "lifespan", "asgi": {"version": "3.0"}, "state": self._state},
|
|
51
|
+
self._lifespan_input_queue.get,
|
|
52
|
+
self._lifespan_output_queue.put,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self._lifespan_task = asyncio.create_task(wrapped_lifespan())
|
|
56
|
+
await self._send_lifespan("startup")
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
async def __aexit__(self, *args: object, **kwargs: Any) -> None:
|
|
60
|
+
"""Shutdown the ASGI application."""
|
|
61
|
+
await self._send_lifespan("shutdown")
|
|
62
|
+
self._lifespan_task = None
|
|
63
|
+
|
|
64
|
+
async def _send_lifespan(self, action: str) -> None:
|
|
65
|
+
assert self._lifespan_task
|
|
66
|
+
|
|
67
|
+
await self._lifespan_input_queue.put({"type": f"lifespan.{action}"})
|
|
68
|
+
message = await asyncio.wait_for(self._lifespan_output_queue.get(), timeout=self._timeout.total_seconds())
|
|
69
|
+
|
|
70
|
+
if message["type"] == f"lifespan.{action}.failed":
|
|
71
|
+
await asyncio.sleep(0)
|
|
72
|
+
if self._lifespan_task.done() and (exc := self._lifespan_task.exception()) is not None:
|
|
73
|
+
raise exc
|
|
74
|
+
raise LifespanError(message)
|
|
75
|
+
|
|
76
|
+
async def __call__(self, request: Request, _next_handler: Next) -> Response:
|
|
77
|
+
"""ASGI middleware handler."""
|
|
78
|
+
scope = await self._request_to_asgi_scope(request)
|
|
79
|
+
body_parts = self._asgi_body_parts(request)
|
|
80
|
+
|
|
81
|
+
send_queue: asyncio.Queue[MutableMapping[str, Any]] = asyncio.Queue()
|
|
82
|
+
|
|
83
|
+
async def receive() -> dict[str, Any]:
|
|
84
|
+
if part := await anext(body_parts, None):
|
|
85
|
+
return part
|
|
86
|
+
return {"type": "http.disconnect"}
|
|
87
|
+
|
|
88
|
+
async def send(message: MutableMapping[str, Any]) -> None:
|
|
89
|
+
await send_queue.put(message)
|
|
90
|
+
|
|
91
|
+
await self._app(scope, receive, send)
|
|
92
|
+
|
|
93
|
+
return await self._asgi_response_to_response(send_queue)
|
|
94
|
+
|
|
95
|
+
async def _request_to_asgi_scope(self, request: Request) -> dict[str, Any]:
|
|
96
|
+
url = request.url
|
|
97
|
+
scope = {
|
|
98
|
+
"type": "http",
|
|
99
|
+
"asgi": {"version": "3.0"},
|
|
100
|
+
"http_version": "1.1",
|
|
101
|
+
"method": request.method.upper(),
|
|
102
|
+
"scheme": url.scheme,
|
|
103
|
+
"path": unquote(url.path),
|
|
104
|
+
"raw_path": url.path.encode(),
|
|
105
|
+
"root_path": "",
|
|
106
|
+
"query_string": (url.query_string or "").encode(),
|
|
107
|
+
"headers": [[name.lower().encode(), value.encode()] for name, value in request.headers.items()],
|
|
108
|
+
"state": self._state.copy(),
|
|
109
|
+
}
|
|
110
|
+
if self._scope_update is not None:
|
|
111
|
+
await self._scope_update(scope, request)
|
|
112
|
+
return scope
|
|
113
|
+
|
|
114
|
+
async def _asgi_body_parts(self, request: Request) -> AsyncIterator[dict[str, Any]]:
|
|
115
|
+
if request.body is None:
|
|
116
|
+
yield {"type": "http.request", "body": b"", "more_body": False}
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
if (stream := request.body.get_stream()) is not None:
|
|
120
|
+
assert isinstance(stream, AsyncIterable)
|
|
121
|
+
body_parts = [bytes(chunk) async for chunk in stream]
|
|
122
|
+
if not body_parts:
|
|
123
|
+
yield {"type": "http.request", "body": b"", "more_body": False}
|
|
124
|
+
return
|
|
125
|
+
*parts, last = body_parts
|
|
126
|
+
for part in parts:
|
|
127
|
+
yield {"type": "http.request", "body": part, "more_body": True}
|
|
128
|
+
yield {"type": "http.request", "body": last, "more_body": False}
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
body_buf = request.body.copy_bytes()
|
|
132
|
+
assert body_buf is not None, "Unknown body type"
|
|
133
|
+
yield {"type": "http.request", "body": body_buf.to_bytes(), "more_body": False}
|
|
134
|
+
|
|
135
|
+
async def _asgi_response_to_response(self, send_queue: asyncio.Queue[MutableMapping[str, Any]]) -> Response:
|
|
136
|
+
response_builder = ResponseBuilder()
|
|
137
|
+
body_parts = []
|
|
138
|
+
|
|
139
|
+
while True:
|
|
140
|
+
message = await asyncio.wait_for(send_queue.get(), timeout=self._timeout.total_seconds())
|
|
141
|
+
|
|
142
|
+
if message["type"] == "http.response.start":
|
|
143
|
+
response_builder.status(message["status"])
|
|
144
|
+
response_builder.headers([(k.decode(), v.decode()) for k, v in message.get("headers", [])])
|
|
145
|
+
|
|
146
|
+
elif message["type"] == "http.response.body":
|
|
147
|
+
if body := message.get("body"):
|
|
148
|
+
body_parts.append(body)
|
|
149
|
+
|
|
150
|
+
if not message.get("more_body", False):
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
if len(body_parts) > 1:
|
|
154
|
+
|
|
155
|
+
async def body_stream() -> AsyncIterator[bytes]:
|
|
156
|
+
for part in body_parts:
|
|
157
|
+
yield part
|
|
158
|
+
|
|
159
|
+
response_builder.body_stream(body_stream())
|
|
160
|
+
|
|
161
|
+
elif len(body_parts) == 1:
|
|
162
|
+
response_builder.body_bytes(body_parts[0])
|
|
163
|
+
|
|
164
|
+
return await response_builder.build()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class LifespanError(Exception):
|
|
168
|
+
"""Error during ASGI application lifespan events."""
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Middleware types and interfaces."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
|
|
5
|
+
from pyreqwest.middleware import Next, SyncNext
|
|
6
|
+
from pyreqwest.request import Request
|
|
7
|
+
from pyreqwest.response import Response, SyncResponse
|
|
8
|
+
|
|
9
|
+
Middleware = Callable[[Request, Next], Awaitable[Response]]
|
|
10
|
+
"""Middleware handler which is called with a request before sending it.
|
|
11
|
+
|
|
12
|
+
Call `await Next.run(Request)` to continue processing the request.
|
|
13
|
+
Alternatively, you can return a custom response via `ResponseBuilder`.
|
|
14
|
+
If you need to forward data down the middleware stack, you can use Request.extensions.
|
|
15
|
+
If you are retrying requests, make sure to clone the request via `Request.copy()` before sending.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
Request: HTTP request to process
|
|
19
|
+
Next: Next middleware in the chain to call
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
HTTP response from the next middleware or a custom response.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
SyncMiddleware = Callable[[Request, SyncNext], SyncResponse]
|
|
26
|
+
"""Sync middleware handler which is used in blocking context. See Middleware for details."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Self
|
|
3
|
+
|
|
4
|
+
from pyreqwest.http import Mime
|
|
5
|
+
from pyreqwest.types import HeadersType, Stream
|
|
6
|
+
|
|
7
|
+
class FormBuilder:
|
|
8
|
+
"""Build multipart/form-data. Chain calls (text, file, part, encoding) then pass to RequestBuilder.multipart()."""
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
"""Creates form builder without any content."""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def boundary(self) -> str:
|
|
14
|
+
"""Get the boundary that this form will use."""
|
|
15
|
+
|
|
16
|
+
def text(self, name: str, value: str) -> Self:
|
|
17
|
+
"""Add a data field with supplied name and value."""
|
|
18
|
+
|
|
19
|
+
async def file(self, name: str, path: Path) -> Self:
|
|
20
|
+
"""Makes a file parameter."""
|
|
21
|
+
|
|
22
|
+
def sync_file(self, name: str, path: Path) -> Self:
|
|
23
|
+
"""Makes a file parameter. File read is blocking."""
|
|
24
|
+
|
|
25
|
+
def part(self, name: str, part: "PartBuilder") -> Self:
|
|
26
|
+
"""Adds a customized part from PartBuilder."""
|
|
27
|
+
|
|
28
|
+
def percent_encode_path_segment(self) -> Self:
|
|
29
|
+
"""Configure this Form to percent-encode using the path-segment rules. This is the default."""
|
|
30
|
+
|
|
31
|
+
def percent_encode_attr_chars(self) -> Self:
|
|
32
|
+
"""Configure this Form to percent-encode using the attr-char rules."""
|
|
33
|
+
|
|
34
|
+
def percent_encode_noop(self) -> Self:
|
|
35
|
+
"""Configure this Form to skip percent-encoding."""
|
|
36
|
+
|
|
37
|
+
class PartBuilder:
|
|
38
|
+
"""Build an individual multipart part. Create with from_* then optionally set mime, filename, headers.
|
|
39
|
+
Add via FormBuilder.part().
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def from_text(value: str) -> "PartBuilder":
|
|
44
|
+
"""Makes a text parameter."""
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def from_bytes(value: bytes) -> "PartBuilder":
|
|
48
|
+
"""Makes a new parameter from arbitrary bytes."""
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def from_stream(stream: Stream) -> "PartBuilder":
|
|
52
|
+
"""Makes a new parameter from an arbitrary stream."""
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def from_stream_with_length(stream: Stream, length: int) -> "PartBuilder":
|
|
56
|
+
"""Makes a new parameter from an arbitrary stream with a known length. This is particularly useful when adding
|
|
57
|
+
something like file contents as a stream, where you can know the content length beforehand.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
async def from_file(path: Path) -> "PartBuilder":
|
|
62
|
+
"""Makes a file parameter."""
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def from_sync_file(path: Path) -> "PartBuilder":
|
|
66
|
+
"""Makes a file parameter. File read is blocking."""
|
|
67
|
+
|
|
68
|
+
def mime(self, mime: Mime | str) -> Self:
|
|
69
|
+
"""Set the mime of this part."""
|
|
70
|
+
|
|
71
|
+
def file_name(self, filename: str) -> Self:
|
|
72
|
+
"""Set the filename."""
|
|
73
|
+
|
|
74
|
+
def headers(self, headers: HeadersType) -> Self:
|
|
75
|
+
"""Sets custom headers for the part."""
|