requ 0.1.0__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.
- requ/__init__.py +434 -0
- requ/_pool.py +88 -0
- requ/py.typed +0 -0
- requ-0.1.0.dist-info/METADATA +65 -0
- requ-0.1.0.dist-info/RECORD +7 -0
- requ-0.1.0.dist-info/WHEEL +4 -0
- requ-0.1.0.dist-info/licenses/LICENSE +21 -0
requ/__init__.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Explicit, bounded HTTP(S) requests using Python's standard library."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from email.message import Message
|
|
6
|
+
from http.client import HTTPConnection, HTTPSConnection, HTTPException, IncompleteRead
|
|
7
|
+
import json as _json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
import ssl
|
|
11
|
+
from urllib.error import HTTPError as _HTTPError, URLError as _URLError
|
|
12
|
+
from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit
|
|
13
|
+
from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener
|
|
14
|
+
|
|
15
|
+
from ._pool import ClientClosedError, Pool, PoolTimeoutError
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
'Client', 'AsyncClient', 'Response', 'JSONValue', 'encode_json', 'ResponseLimitError', 'RequestLimitError',
|
|
19
|
+
'RedirectError', 'ContentEncodingError', 'HTTPStatusError',
|
|
20
|
+
'TransportError', 'TransportTimeoutError', 'ClientClosedError', 'PoolTimeoutError',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = '0.1.0'
|
|
24
|
+
|
|
25
|
+
type JSONValue = None | bool | int | float | str | list[JSONValue] | dict[str, JSONValue]
|
|
26
|
+
|
|
27
|
+
_TOKEN = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+\Z")
|
|
28
|
+
_RESERVED = {
|
|
29
|
+
'host', 'connection', 'content-length', 'transfer-encoding',
|
|
30
|
+
'proxy-authorization', 'proxy-connection', 'accept-encoding',
|
|
31
|
+
'keep-alive', 'te', 'trailer', 'upgrade', 'expect',
|
|
32
|
+
}
|
|
33
|
+
_REDIRECTS = {301, 302, 303, 307, 308}
|
|
34
|
+
_MISSING = object()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ResponseLimitError(ValueError):
|
|
38
|
+
"""The response exceeds the configured byte limit."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RequestLimitError(ValueError):
|
|
42
|
+
"""The request body exceeds the configured byte limit."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RedirectError(ValueError):
|
|
46
|
+
"""A redirect violates the selected count, origin, scheme or cycle policy."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ContentEncodingError(ValueError):
|
|
50
|
+
"""A server sent content encoding other than identity."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TransportError(OSError):
|
|
54
|
+
"""A connection, TLS, socket or HTTP protocol failure; never auto-retried."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TransportTimeoutError(TransportError, TimeoutError):
|
|
58
|
+
"""A blocking socket operation timed out (not a total/DNS deadline)."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HTTPStatusError(RuntimeError):
|
|
62
|
+
"""An explicit raise_for_status rejected a 4xx/5xx response."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, response: 'Response') -> None:
|
|
65
|
+
self.response = response
|
|
66
|
+
super().__init__(f'HTTP status {response.status}')
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _url(value: str) -> str:
|
|
70
|
+
if not isinstance(value, str):
|
|
71
|
+
raise TypeError('URL must be a string')
|
|
72
|
+
if not value or not value.isascii() or any(ord(char) <= 32 or ord(char) == 127 for char in value):
|
|
73
|
+
raise ValueError('URL must be ASCII with no whitespace or control characters')
|
|
74
|
+
parts = urlsplit(value)
|
|
75
|
+
if parts.scheme.lower() not in {'http', 'https'} or not parts.hostname:
|
|
76
|
+
raise ValueError('an absolute HTTP(S) URL is required')
|
|
77
|
+
if parts.username is not None or parts.password is not None:
|
|
78
|
+
raise ValueError('URL credentials are not supported')
|
|
79
|
+
if parts.port is not None and not 1 <= parts.port <= 65535:
|
|
80
|
+
raise ValueError('invalid URL port')
|
|
81
|
+
if '\\' in parts.netloc or '%' in parts.netloc:
|
|
82
|
+
raise ValueError('invalid URL authority')
|
|
83
|
+
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path or '/', parts.query, ''))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _origin(url: str) -> tuple[str, str, int]:
|
|
87
|
+
parts = urlsplit(url)
|
|
88
|
+
return parts.scheme, parts.hostname, parts.port or (443 if parts.scheme == 'https' else 80)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _headers(headers: Mapping[str, str] | None) -> dict[str, str]:
|
|
92
|
+
if headers is not None and not isinstance(headers, Mapping):
|
|
93
|
+
raise TypeError('headers must be a mapping or None')
|
|
94
|
+
supplied = {}
|
|
95
|
+
for name, value in (headers or {}).items():
|
|
96
|
+
if not isinstance(name, str) or not isinstance(value, str):
|
|
97
|
+
raise TypeError('header names and values must be strings')
|
|
98
|
+
if not _TOKEN.fullmatch(name) or name.lower() in _RESERVED:
|
|
99
|
+
raise ValueError('invalid or reserved request header')
|
|
100
|
+
if any(ord(char) < 32 or ord(char) == 127 or ord(char) > 255 for char in value):
|
|
101
|
+
raise ValueError('header values must be Latin-1 without control characters')
|
|
102
|
+
key = name.lower()
|
|
103
|
+
if key in supplied:
|
|
104
|
+
raise ValueError('duplicate case-insensitive request header')
|
|
105
|
+
supplied[key] = value
|
|
106
|
+
return supplied
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def encode_json(value: JSONValue) -> bytes:
|
|
110
|
+
"""Encode finite JSON with string object keys and no implicit coercions."""
|
|
111
|
+
# The encoder detects cycles before the structural traversal below.
|
|
112
|
+
result = _json.dumps(value, allow_nan=False, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
|
|
113
|
+
|
|
114
|
+
def check(item):
|
|
115
|
+
if type(item) is dict:
|
|
116
|
+
if any(type(key) is not str for key in item):
|
|
117
|
+
raise TypeError('JSON object keys must be strings')
|
|
118
|
+
for child in item.values():
|
|
119
|
+
check(child)
|
|
120
|
+
elif type(item) is list:
|
|
121
|
+
for child in item:
|
|
122
|
+
check(child)
|
|
123
|
+
elif item is not None and type(item) not in (str, bool, int, float):
|
|
124
|
+
raise TypeError('value is not a JSON value')
|
|
125
|
+
check(value)
|
|
126
|
+
return result
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _constant(value: str):
|
|
130
|
+
raise ValueError('non-finite JSON number')
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _object(pairs):
|
|
134
|
+
result = {}
|
|
135
|
+
for name, value in pairs:
|
|
136
|
+
if name in result:
|
|
137
|
+
raise ValueError('duplicate JSON object member')
|
|
138
|
+
result[name] = value
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _finite_float(value: str) -> float:
|
|
143
|
+
result = float(value)
|
|
144
|
+
if not math.isfinite(result):
|
|
145
|
+
raise ValueError('non-finite JSON number')
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
150
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class Response:
|
|
156
|
+
"""Owned bytes and ordered duplicate headers; no live response stream."""
|
|
157
|
+
|
|
158
|
+
url: str
|
|
159
|
+
status: int
|
|
160
|
+
headers: tuple[tuple[str, str], ...]
|
|
161
|
+
body: bytes = field(repr=False)
|
|
162
|
+
|
|
163
|
+
def headers_all(self, name: str) -> tuple[str, ...]:
|
|
164
|
+
return tuple(value for key, value in self.headers if key.lower() == name.lower())
|
|
165
|
+
|
|
166
|
+
def header(self, name: str) -> str | None:
|
|
167
|
+
return next((value for key, value in self.headers if key.lower() == name.lower()), None)
|
|
168
|
+
|
|
169
|
+
def decode(self, encoding: str | None = None, *, errors: str = 'strict') -> str:
|
|
170
|
+
if encoding is None:
|
|
171
|
+
message = Message()
|
|
172
|
+
message['Content-Type'] = self.header('content-type') or ''
|
|
173
|
+
encoding = message.get_content_charset() or 'utf-8'
|
|
174
|
+
return self.body.decode(encoding, errors)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def text(self) -> str:
|
|
178
|
+
return self.decode()
|
|
179
|
+
|
|
180
|
+
def json(self) -> JSONValue:
|
|
181
|
+
"""Decode UTF-8 JSON; reject duplicate members and non-finite numbers."""
|
|
182
|
+
return _json.loads(self.body.decode('utf-8'), parse_constant=_constant,
|
|
183
|
+
parse_float=_finite_float, object_pairs_hook=_object)
|
|
184
|
+
|
|
185
|
+
def raise_for_status(self) -> None:
|
|
186
|
+
if 400 <= self.status <= 599:
|
|
187
|
+
raise HTTPStatusError(self)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, slots=True)
|
|
191
|
+
class Client:
|
|
192
|
+
"""Client-owned bounded HTTP/1.1 pool; requests are never auto-retried.
|
|
193
|
+
|
|
194
|
+
timeout bounds blocking socket operations, not total time or DNS. Environment
|
|
195
|
+
proxy opt-in uses urllib's non-pooled transport. URLs are not SSRF-filtered.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
timeout: float = 10.0
|
|
199
|
+
max_bytes: int = 5_000_000
|
|
200
|
+
max_redirects: int = 5
|
|
201
|
+
allow_cross_origin_redirects: bool = False
|
|
202
|
+
trust_env: bool = False
|
|
203
|
+
max_request_bytes: int = 5_000_000
|
|
204
|
+
max_connections: int = 10
|
|
205
|
+
pool_timeout: float = 10.0
|
|
206
|
+
keepalive_expiry: float = 30.0
|
|
207
|
+
_pool: Pool = field(init=False, repr=False, compare=False)
|
|
208
|
+
_context: ssl.SSLContext = field(init=False, repr=False, compare=False)
|
|
209
|
+
|
|
210
|
+
def __post_init__(self) -> None:
|
|
211
|
+
for name in ('timeout', 'pool_timeout', 'keepalive_expiry'):
|
|
212
|
+
value = getattr(self, name)
|
|
213
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
214
|
+
raise TypeError(f'{name} must be a finite positive number')
|
|
215
|
+
if not math.isfinite(value) or value <= 0:
|
|
216
|
+
raise ValueError(f'{name} must be a finite positive number')
|
|
217
|
+
for name, minimum in (('max_bytes', 1), ('max_request_bytes', 1), ('max_redirects', 0), ('max_connections', 1)):
|
|
218
|
+
value = getattr(self, name)
|
|
219
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
220
|
+
raise TypeError(f'{name} must be an integer')
|
|
221
|
+
if value < minimum:
|
|
222
|
+
raise ValueError(f'{name} must be at least {minimum}')
|
|
223
|
+
if not isinstance(self.allow_cross_origin_redirects, bool) or not isinstance(self.trust_env, bool):
|
|
224
|
+
raise TypeError('redirect and proxy flags must be bools')
|
|
225
|
+
context = ssl.create_default_context()
|
|
226
|
+
context.set_alpn_protocols(['http/1.1'])
|
|
227
|
+
object.__setattr__(self, '_context', context)
|
|
228
|
+
object.__setattr__(self, '_pool', Pool(self.max_connections, self.pool_timeout, self.keepalive_expiry))
|
|
229
|
+
|
|
230
|
+
def close(self) -> None:
|
|
231
|
+
self._pool.close()
|
|
232
|
+
|
|
233
|
+
def __enter__(self):
|
|
234
|
+
self._pool.check_open()
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
def __exit__(self, *exc) -> None:
|
|
238
|
+
self.close()
|
|
239
|
+
|
|
240
|
+
def get(self, url: str, **kwargs) -> Response:
|
|
241
|
+
return self.request('GET', url, **kwargs)
|
|
242
|
+
|
|
243
|
+
def head(self, url: str, **kwargs) -> Response:
|
|
244
|
+
return self.request('HEAD', url, **kwargs)
|
|
245
|
+
|
|
246
|
+
def post(self, url: str, **kwargs) -> Response:
|
|
247
|
+
return self.request('POST', url, **kwargs)
|
|
248
|
+
|
|
249
|
+
def put(self, url: str, **kwargs) -> Response:
|
|
250
|
+
return self.request('PUT', url, **kwargs)
|
|
251
|
+
|
|
252
|
+
def patch(self, url: str, **kwargs) -> Response:
|
|
253
|
+
return self.request('PATCH', url, **kwargs)
|
|
254
|
+
|
|
255
|
+
def delete(self, url: str, **kwargs) -> Response:
|
|
256
|
+
return self.request('DELETE', url, **kwargs)
|
|
257
|
+
|
|
258
|
+
def request(self, method: str, url: str, *, headers: Mapping[str, str] | None = None,
|
|
259
|
+
body: bytes | None = None, json=_MISSING,
|
|
260
|
+
params: Mapping | Sequence[tuple] | None = None) -> Response:
|
|
261
|
+
self._pool.check_open()
|
|
262
|
+
if not isinstance(method, str) or not _TOKEN.fullmatch(method):
|
|
263
|
+
raise ValueError('method must be an HTTP token')
|
|
264
|
+
method = method.upper()
|
|
265
|
+
if method in {'CONNECT', 'TRACE'}:
|
|
266
|
+
raise ValueError('CONNECT and TRACE are not supported')
|
|
267
|
+
current = _url(url)
|
|
268
|
+
if params is not None:
|
|
269
|
+
query = urlencode(params, doseq=True)
|
|
270
|
+
if query:
|
|
271
|
+
current += ('&' if urlsplit(current).query else '?') + query
|
|
272
|
+
supplied = _headers(headers)
|
|
273
|
+
if json is not _MISSING:
|
|
274
|
+
if body is not None:
|
|
275
|
+
raise ValueError('body and json are mutually exclusive')
|
|
276
|
+
body = encode_json(json)
|
|
277
|
+
supplied.setdefault('content-type', 'application/json')
|
|
278
|
+
if body is not None and not isinstance(body, bytes):
|
|
279
|
+
raise TypeError('body must be bytes or None')
|
|
280
|
+
if body is not None and len(body) > self.max_request_bytes:
|
|
281
|
+
raise RequestLimitError('request exceeds max_request_bytes')
|
|
282
|
+
if body is not None and method in {'GET', 'HEAD'}:
|
|
283
|
+
raise ValueError('GET and HEAD request bodies are not supported')
|
|
284
|
+
visited = {current}
|
|
285
|
+
for redirects in range(self.max_redirects + 1):
|
|
286
|
+
response = self._send(method, current, supplied, body)
|
|
287
|
+
location = response.header('location')
|
|
288
|
+
if method not in {'GET', 'HEAD'} or response.status not in _REDIRECTS or location is None:
|
|
289
|
+
return response
|
|
290
|
+
if redirects >= self.max_redirects:
|
|
291
|
+
raise RedirectError('max_redirects exceeded')
|
|
292
|
+
try:
|
|
293
|
+
if not location.isascii() or any(ord(c) <= 32 or ord(c) == 127 for c in location):
|
|
294
|
+
raise ValueError('invalid redirect characters')
|
|
295
|
+
target = _url(urljoin(current, location))
|
|
296
|
+
except (TypeError, ValueError) as error:
|
|
297
|
+
raise RedirectError('invalid redirect target') from error
|
|
298
|
+
if urlsplit(current).scheme == 'https' and urlsplit(target).scheme == 'http':
|
|
299
|
+
raise RedirectError('HTTPS downgrade is forbidden')
|
|
300
|
+
if _origin(current) != _origin(target):
|
|
301
|
+
if not self.allow_cross_origin_redirects:
|
|
302
|
+
raise RedirectError('cross-origin redirect requires explicit opt-in')
|
|
303
|
+
supplied = {}
|
|
304
|
+
if target in visited:
|
|
305
|
+
raise RedirectError('redirect cycle')
|
|
306
|
+
visited.add(target)
|
|
307
|
+
current = target
|
|
308
|
+
raise AssertionError('unreachable')
|
|
309
|
+
|
|
310
|
+
def _send(self, method: str, url: str, supplied: dict, body: bytes | None) -> Response:
|
|
311
|
+
headers = {'user-agent': 'requ/0.1.0', **supplied, 'accept-encoding': 'identity'}
|
|
312
|
+
connection = None
|
|
313
|
+
raw = None
|
|
314
|
+
reusable = False
|
|
315
|
+
key = _origin(url)
|
|
316
|
+
try:
|
|
317
|
+
if self.trust_env:
|
|
318
|
+
# A lease still bounds concurrent proxy requests; urllib owns
|
|
319
|
+
# actual proxy routing/CONNECT and closes its transport.
|
|
320
|
+
connection = self._pool.acquire(key, lambda: HTTPConnection(key[1], key[2]))
|
|
321
|
+
opener = build_opener(_NoRedirect(), ProxyHandler(), HTTPSHandler(context=self._context))
|
|
322
|
+
request = Request(url, data=body, headers=headers, method=method)
|
|
323
|
+
try:
|
|
324
|
+
raw = opener.open(request, timeout=self.timeout)
|
|
325
|
+
except _HTTPError as error:
|
|
326
|
+
raw = error
|
|
327
|
+
else:
|
|
328
|
+
kind = HTTPSConnection if key[0] == 'https' else HTTPConnection
|
|
329
|
+
options = {'timeout': self.timeout}
|
|
330
|
+
if key[0] == 'https':
|
|
331
|
+
options['context'] = self._context
|
|
332
|
+
connection = self._pool.acquire(key, lambda: kind(key[1], key[2], **options))
|
|
333
|
+
parts = urlsplit(url)
|
|
334
|
+
target = urlunsplit(('', '', parts.path, parts.query, ''))
|
|
335
|
+
connection.request(method, target, body=body, headers=headers)
|
|
336
|
+
raw = connection.getresponse()
|
|
337
|
+
response = self._read(raw, method, url)
|
|
338
|
+
reusable = not self.trust_env and not getattr(raw, 'will_close', True)
|
|
339
|
+
return response
|
|
340
|
+
except (ClientClosedError, PoolTimeoutError):
|
|
341
|
+
raise
|
|
342
|
+
except (OSError, HTTPException, _URLError) as error:
|
|
343
|
+
reason = error.reason if isinstance(error, _URLError) else error
|
|
344
|
+
kind = TransportTimeoutError if isinstance(reason, TimeoutError) else TransportError
|
|
345
|
+
raise kind('HTTP transport failed') from error
|
|
346
|
+
finally:
|
|
347
|
+
if raw is not None:
|
|
348
|
+
raw.close()
|
|
349
|
+
if connection is not None:
|
|
350
|
+
self._pool.release(key, connection, reusable)
|
|
351
|
+
|
|
352
|
+
def _read(self, raw, method: str, url: str) -> Response:
|
|
353
|
+
if raw.status < 200:
|
|
354
|
+
raise ValueError('protocol switching is not supported')
|
|
355
|
+
no_body = method == 'HEAD' or raw.status in {204, 304}
|
|
356
|
+
encodings = raw.headers.get_all('Content-Encoding', [])
|
|
357
|
+
if not no_body and any(v.strip().lower() not in {'', 'identity'} for v in encodings):
|
|
358
|
+
raise ContentEncodingError('only identity Content-Encoding is supported')
|
|
359
|
+
lengths = raw.headers.get_all('Content-Length', [])
|
|
360
|
+
transfers = raw.headers.get_all('Transfer-Encoding', [])
|
|
361
|
+
if transfers and (lengths or [v.strip().lower() for v in transfers] != ['chunked']):
|
|
362
|
+
raise ValueError('ambiguous or unsupported response framing')
|
|
363
|
+
length = None
|
|
364
|
+
if lengths:
|
|
365
|
+
if any(not v.strip().isascii() or not v.strip().isdigit() or len(v.strip()) > 20 for v in lengths):
|
|
366
|
+
raise ValueError('invalid Content-Length')
|
|
367
|
+
if len({int(v.strip()) for v in lengths}) != 1:
|
|
368
|
+
raise ValueError('conflicting Content-Length')
|
|
369
|
+
length = int(lengths[0].strip())
|
|
370
|
+
if not no_body and length > self.max_bytes:
|
|
371
|
+
raise ResponseLimitError('declared response exceeds max_bytes')
|
|
372
|
+
pieces = []
|
|
373
|
+
size = 0
|
|
374
|
+
if not no_body:
|
|
375
|
+
while True:
|
|
376
|
+
piece = raw.read1(min(65_536, self.max_bytes - size + 1))
|
|
377
|
+
if not piece:
|
|
378
|
+
break
|
|
379
|
+
size += len(piece)
|
|
380
|
+
if size > self.max_bytes:
|
|
381
|
+
raise ResponseLimitError('response exceeds max_bytes')
|
|
382
|
+
pieces.append(piece)
|
|
383
|
+
result = b''.join(pieces)
|
|
384
|
+
if not no_body and length is not None and size < length:
|
|
385
|
+
raise IncompleteRead(result, length - size)
|
|
386
|
+
return Response(url, raw.status, tuple(raw.headers.items()), result)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class AsyncClient:
|
|
390
|
+
"""Worker-thread facade over Client, sharing its limits and owned pool.
|
|
391
|
+
|
|
392
|
+
Cancellation stops awaiting; dispatched requests can still complete. Closing
|
|
393
|
+
prevents further leases and retires active connections on their return.
|
|
394
|
+
"""
|
|
395
|
+
|
|
396
|
+
def __init__(self, **options) -> None:
|
|
397
|
+
self._client = Client(**options)
|
|
398
|
+
|
|
399
|
+
@property
|
|
400
|
+
def allow_cross_origin_redirects(self) -> bool:
|
|
401
|
+
"""Read-only redirect configuration for higher-level scoped clients."""
|
|
402
|
+
return self._client.allow_cross_origin_redirects
|
|
403
|
+
|
|
404
|
+
async def request(self, method: str, url: str, **kwargs) -> Response:
|
|
405
|
+
import asyncio
|
|
406
|
+
return await asyncio.to_thread(self._client.request, method, url, **kwargs)
|
|
407
|
+
|
|
408
|
+
async def get(self, url: str, **kwargs) -> Response:
|
|
409
|
+
return await self.request('GET', url, **kwargs)
|
|
410
|
+
|
|
411
|
+
async def head(self, url: str, **kwargs) -> Response:
|
|
412
|
+
return await self.request('HEAD', url, **kwargs)
|
|
413
|
+
|
|
414
|
+
async def post(self, url: str, **kwargs) -> Response:
|
|
415
|
+
return await self.request('POST', url, **kwargs)
|
|
416
|
+
|
|
417
|
+
async def put(self, url: str, **kwargs) -> Response:
|
|
418
|
+
return await self.request('PUT', url, **kwargs)
|
|
419
|
+
|
|
420
|
+
async def patch(self, url: str, **kwargs) -> Response:
|
|
421
|
+
return await self.request('PATCH', url, **kwargs)
|
|
422
|
+
|
|
423
|
+
async def delete(self, url: str, **kwargs) -> Response:
|
|
424
|
+
return await self.request('DELETE', url, **kwargs)
|
|
425
|
+
|
|
426
|
+
async def aclose(self) -> None:
|
|
427
|
+
self._client.close()
|
|
428
|
+
|
|
429
|
+
async def __aenter__(self):
|
|
430
|
+
self._client._pool.check_open()
|
|
431
|
+
return self
|
|
432
|
+
|
|
433
|
+
async def __aexit__(self, *exc) -> None:
|
|
434
|
+
await self.aclose()
|
requ/_pool.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Bounded, client-owned HTTP/1.1 connection leases."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from http.client import HTTPConnection
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClientClosedError(RuntimeError):
|
|
10
|
+
"""The client no longer accepts requests."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PoolTimeoutError(TimeoutError):
|
|
14
|
+
"""No connection lease became available in time."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Pool:
|
|
18
|
+
def __init__(self, capacity: int, timeout: float, expiry: float) -> None:
|
|
19
|
+
self.capacity = capacity
|
|
20
|
+
self.timeout = timeout
|
|
21
|
+
self.expiry = expiry
|
|
22
|
+
self._condition = threading.Condition()
|
|
23
|
+
self._idle: list[tuple[tuple, HTTPConnection, float]] = []
|
|
24
|
+
self._total = 0
|
|
25
|
+
self._closed = False
|
|
26
|
+
|
|
27
|
+
def check_open(self) -> None:
|
|
28
|
+
with self._condition:
|
|
29
|
+
if self._closed:
|
|
30
|
+
raise ClientClosedError('client is closed')
|
|
31
|
+
|
|
32
|
+
def acquire(self, key: tuple, factory: Callable[[], HTTPConnection]) -> HTTPConnection:
|
|
33
|
+
deadline = time.monotonic() + self.timeout
|
|
34
|
+
with self._condition:
|
|
35
|
+
while True:
|
|
36
|
+
if self._closed:
|
|
37
|
+
raise ClientClosedError('client is closed')
|
|
38
|
+
now = time.monotonic()
|
|
39
|
+
keep = []
|
|
40
|
+
for entry in self._idle:
|
|
41
|
+
if now - entry[2] >= self.expiry:
|
|
42
|
+
entry[1].close()
|
|
43
|
+
self._total -= 1
|
|
44
|
+
else:
|
|
45
|
+
keep.append(entry)
|
|
46
|
+
self._idle = keep
|
|
47
|
+
for index, entry in enumerate(self._idle):
|
|
48
|
+
if entry[0] == key:
|
|
49
|
+
return self._idle.pop(index)[1]
|
|
50
|
+
if self._total < self.capacity:
|
|
51
|
+
self._total += 1
|
|
52
|
+
break
|
|
53
|
+
if self._idle:
|
|
54
|
+
self._idle.pop(0)[1].close()
|
|
55
|
+
break
|
|
56
|
+
remaining = deadline - now
|
|
57
|
+
if remaining <= 0:
|
|
58
|
+
raise PoolTimeoutError('connection lease timed out')
|
|
59
|
+
self._condition.wait(remaining)
|
|
60
|
+
try:
|
|
61
|
+
return factory()
|
|
62
|
+
except BaseException:
|
|
63
|
+
with self._condition:
|
|
64
|
+
self._total -= 1
|
|
65
|
+
self._condition.notify()
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
def release(self, key: tuple, connection: HTTPConnection, reusable: bool) -> None:
|
|
69
|
+
with self._condition:
|
|
70
|
+
if reusable and not self._closed:
|
|
71
|
+
self._idle.append((key, connection, time.monotonic()))
|
|
72
|
+
else:
|
|
73
|
+
connection.close()
|
|
74
|
+
self._total -= 1
|
|
75
|
+
self._condition.notify()
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
with self._condition:
|
|
79
|
+
self._closed = True
|
|
80
|
+
for _, connection, _ in self._idle:
|
|
81
|
+
connection.close()
|
|
82
|
+
self._total -= len(self._idle)
|
|
83
|
+
self._idle.clear()
|
|
84
|
+
self._condition.notify_all()
|
|
85
|
+
|
|
86
|
+
def __del__(self) -> None:
|
|
87
|
+
if hasattr(self, '_condition'):
|
|
88
|
+
self.close()
|
requ/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: requ
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bounded HTTP(S) requests with client-owned connection pooling
|
|
5
|
+
Author: ellainng
|
|
6
|
+
Author-email: ellainng <299958882+ellainng@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.14
|
|
16
|
+
Project-URL: Homepage, https://github.com/wispsy/requ
|
|
17
|
+
Project-URL: Repository, https://github.com/wispsy/requ
|
|
18
|
+
Project-URL: Issues, https://github.com/wispsy/requ/issues
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# requ
|
|
22
|
+
|
|
23
|
+
Explicit, bounded HTTP(S) requests with Python's standard library. Python 3.14+;
|
|
24
|
+
no runtime dependencies.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install requ
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from requ import Client
|
|
32
|
+
|
|
33
|
+
with Client(timeout=10, max_bytes=2_000_000, max_connections=4) as client:
|
|
34
|
+
response = client.post('https://api.example.com/users', json={'name': 'Ash'})
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
user = response.json()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Use `AsyncClient` with `async with` and `await client.request(...)` for asynchronous
|
|
40
|
+
applications. Its I/O runs in worker threads. Canceling an await does not undo or
|
|
41
|
+
stop a dispatched request; the connection remains leased until the worker returns.
|
|
42
|
+
|
|
43
|
+
- General methods, query pairs, bounded bytes/finite JSON and owned responses.
|
|
44
|
+
- Client-owned HTTP/1.1 pool with finite capacity, lease wait and idle expiry.
|
|
45
|
+
- Verified TLS, validated headers, strict framing and bounded redirects.
|
|
46
|
+
- No retries, cookies, automatic decompression or global opener changes.
|
|
47
|
+
- Environment proxies require `trust_env=True`; proxy requests use non-pooled
|
|
48
|
+
urllib transport while retaining the client's concurrency/body/socket bounds.
|
|
49
|
+
|
|
50
|
+
`timeout` bounds individual blocking socket operations. It is **not** a DNS or total
|
|
51
|
+
wall-clock deadline. Direct sockets remain in the pool until expiry or `close()`;
|
|
52
|
+
responses themselves contain only owned bytes. Always use a context or close the
|
|
53
|
+
client when finished. GET/HEAD may follow bounded redirects; mutations return their
|
|
54
|
+
redirect response without replay. Cross-origin redirect opt-in strips caller headers.
|
|
55
|
+
|
|
56
|
+
See [the API reference](docs/api.md) for the precise limits and error contract.
|
|
57
|
+
API resources, credentials and GraphQL belong in a higher-level library such as
|
|
58
|
+
[apiconn](https://github.com/wispsy/apiconn).
|
|
59
|
+
|
|
60
|
+
Development: `uv sync`, then
|
|
61
|
+
`uv run python -W error::ResourceWarning -m unittest discover -s tests -v`.
|
|
62
|
+
Build and independently test both distributions with
|
|
63
|
+
`uv run python scripts/check_distribution.py`.
|
|
64
|
+
|
|
65
|
+
MIT.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
requ/__init__.py,sha256=soSbkjjs3LvCsQ79hHYb3Tnm3bceorJxeFB9G0Z2Tk8,18401
|
|
2
|
+
requ/_pool.py,sha256=v5c1A5nF2btwDcp8ALYY8iQTktk860kxvQx-9Vd-qr8,3009
|
|
3
|
+
requ/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
requ-0.1.0.dist-info/licenses/LICENSE,sha256=pAeG0B2X4uxLV9iLuLxEEk4YSQamiNrWf4E6P5vch1Q,1069
|
|
5
|
+
requ-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
|
|
6
|
+
requ-0.1.0.dist-info/METADATA,sha256=0V6bnZYiFGpALKOhgwJgSS-h12kCkGWLJebiqieFyls,2697
|
|
7
|
+
requ-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Outside Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|