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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.