syshttp 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.
- syshttp/AGENTS.md +817 -0
- syshttp/README.md +177 -0
- syshttp/__init__.py +71 -0
- syshttp/_fileio.py +78 -0
- syshttp/_parse.py +30 -0
- syshttp/auth.py +17 -0
- syshttp/autopatch.py +382 -0
- syshttp/client/__init__.py +70 -0
- syshttp/client/_body.py +156 -0
- syshttp/client/_client.py +413 -0
- syshttp/client/_context.py +107 -0
- syshttp/client/_deadline.py +94 -0
- syshttp/client/_default.py +72 -0
- syshttp/client/_fetch.py +98 -0
- syshttp/client/_follow.py +243 -0
- syshttp/client/_headers.py +238 -0
- syshttp/client/_pool.py +170 -0
- syshttp/client/_prepare.py +77 -0
- syshttp/client/_response.py +301 -0
- syshttp/client/_route.py +75 -0
- syshttp/client/_settings.py +12 -0
- syshttp/client/_state.py +322 -0
- syshttp/client/_status.py +59 -0
- syshttp/client/_tlstunnel.py +114 -0
- syshttp/client/_transport.py +550 -0
- syshttp/cookies.py +391 -0
- syshttp/env.py +144 -0
- syshttp/exceptions.py +29 -0
- syshttp/hooks.py +314 -0
- syshttp/jar.py +133 -0
- syshttp/proxy.py +341 -0
- syshttp/py.typed +0 -0
- syshttp/retry.py +34 -0
- syshttp/tls.py +60 -0
- syshttp-0.1.0.dist-info/METADATA +219 -0
- syshttp-0.1.0.dist-info/RECORD +39 -0
- syshttp-0.1.0.dist-info/WHEEL +4 -0
- syshttp-0.1.0.dist-info/entry_points.txt +6 -0
- syshttp-0.1.0.dist-info/licenses/LICENSE +21 -0
syshttp/autopatch.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Patch HTTP client libraries as they are imported, so they route and verify
|
|
2
|
+
the way syshttp does: routing by :func:`syshttp.proxy.proxy_map` through
|
|
3
|
+
proxylib's client integrations (``proxylib.patch``), and TLS by the system's
|
|
4
|
+
context (:func:`syshttp.tls.os_ssl_context`).
|
|
5
|
+
|
|
6
|
+
``SYSHTTP_IMPORTPATCH`` chooses the libraries -- read under the prefixes as
|
|
7
|
+
every setting is (``SYSHTTP_PREFIX=AGENTS_``: ``AGENTS_SYSHTTP_IMPORTPATCH``): ``1``/``true``/``yes``/``on`` for all of
|
|
8
|
+
:data:`TARGETS`, ``0``/``false``/``no``/``off``, empty or unset for none, else
|
|
9
|
+
their names separated by ``:`` (``requests:httpx``; a module name such as
|
|
10
|
+
``urllib.request`` works too). It is read when the library is imported:
|
|
11
|
+
importpatch (a dependency) calls :func:`importpatch` for each target, through
|
|
12
|
+
this distribution's ``importpatch`` entry points. :func:`patch` applies the
|
|
13
|
+
same patches from code, whatever the setting says.
|
|
14
|
+
|
|
15
|
+
What the caller chose always wins: a proxy given to the client, a context, a
|
|
16
|
+
CA bundle or ``verify=False``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
import importlib
|
|
23
|
+
import logging
|
|
24
|
+
import ssl
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from types import ModuleType
|
|
29
|
+
from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
# Neither proxylib nor syshttp.proxy is imported here: proxylib's own import
|
|
32
|
+
# of urllib.request runs this module's hook while proxylib is half built.
|
|
33
|
+
from .env import HTTPEnv
|
|
34
|
+
|
|
35
|
+
__all__ = ["SETTING", "TARGETS", "enabled", "importpatch", "patch", "patched", "setting_name", "unpatch"]
|
|
36
|
+
|
|
37
|
+
#: The setting, read under every prefix as any other is (:func:`setting_name`).
|
|
38
|
+
SETTING = "SYSHTTP_IMPORTPATCH"
|
|
39
|
+
#: The libraries that can be patched: name -> the module whose import patches it.
|
|
40
|
+
TARGETS: Dict[str, str] = {
|
|
41
|
+
"requests": "requests",
|
|
42
|
+
"urllib": "urllib.request",
|
|
43
|
+
"httpx": "httpx",
|
|
44
|
+
"aiohttp": "aiohttp",
|
|
45
|
+
"pip": "pip._vendor.requests",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_log = logging.getLogger(__name__)
|
|
49
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
50
|
+
_FALSE = ("", "0", "false", "no", "off")
|
|
51
|
+
_BY_MODULE = {module: name for name, module in TARGETS.items()}
|
|
52
|
+
_lock = threading.RLock()
|
|
53
|
+
# name -> the functions that undo its patch, in the order applied.
|
|
54
|
+
_undo: Dict[str, List[Callable[[], None]]] = {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def setting_name(*, env: Optional[HTTPEnv] = None) -> str:
|
|
58
|
+
"""The variable that chooses the libraries: ``<PREFIX>SYSHTTP_IMPORTPATCH``
|
|
59
|
+
for the first prefix that sets it (``AGENTS_SYSHTTP_IMPORTPATCH`` under
|
|
60
|
+
``SYSHTTP_PREFIX=AGENTS_``), else the name under the first prefix."""
|
|
61
|
+
env = HTTPEnv() if env is None else env
|
|
62
|
+
found = env.find(SETTING)
|
|
63
|
+
return found[0] if found else env.name(SETTING)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def enabled(*, env: Optional[HTTPEnv] = None) -> FrozenSet[str]:
|
|
67
|
+
"""The names of :data:`TARGETS` that :func:`setting_name` enables, ``:``
|
|
68
|
+
separated. A name that is not a target is logged at WARNING and skipped."""
|
|
69
|
+
env = HTTPEnv() if env is None else env
|
|
70
|
+
name = setting_name(env=env)
|
|
71
|
+
raw = env.get(SETTING) or ""
|
|
72
|
+
if raw.lower() in _FALSE:
|
|
73
|
+
return frozenset()
|
|
74
|
+
if raw.lower() in _TRUE:
|
|
75
|
+
return frozenset(TARGETS)
|
|
76
|
+
names = set()
|
|
77
|
+
for part in raw.split(":"):
|
|
78
|
+
part = part.strip()
|
|
79
|
+
if not part:
|
|
80
|
+
continue
|
|
81
|
+
target = _BY_MODULE.get(part, part)
|
|
82
|
+
if target in TARGETS:
|
|
83
|
+
names.add(target)
|
|
84
|
+
else:
|
|
85
|
+
_log.warning("%s names %r, which is not one of %s", name, part, ", ".join(TARGETS))
|
|
86
|
+
return frozenset(names)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def importpatch(name: str, module: ModuleType) -> None:
|
|
90
|
+
"""importpatch's entry point: patch ``module``, just imported as ``name``,
|
|
91
|
+
when :func:`setting_name` enables it."""
|
|
92
|
+
_flush()
|
|
93
|
+
target = _BY_MODULE.get(name)
|
|
94
|
+
if target is not None and target in enabled():
|
|
95
|
+
_apply(target, module)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def patch(targets: Optional[Iterable[str]] = None) -> Tuple[str, ...]:
|
|
99
|
+
"""Patch ``targets`` now (default: every target whose library is
|
|
100
|
+
installed), importing each library. A name that is not a target is a
|
|
101
|
+
``ValueError``; a named library that is not installed raises its
|
|
102
|
+
``ImportError``. Returns the names patched; patching twice does nothing."""
|
|
103
|
+
names = list(TARGETS) if targets is None else [_BY_MODULE.get(t, t) for t in targets]
|
|
104
|
+
for name in names:
|
|
105
|
+
if name not in TARGETS:
|
|
106
|
+
raise ValueError("%r is not one of %s" % (name, ", ".join(TARGETS)))
|
|
107
|
+
_flush()
|
|
108
|
+
done = []
|
|
109
|
+
for name in names:
|
|
110
|
+
try:
|
|
111
|
+
module = importlib.import_module(TARGETS[name])
|
|
112
|
+
except ImportError:
|
|
113
|
+
if targets is not None:
|
|
114
|
+
raise
|
|
115
|
+
continue
|
|
116
|
+
_apply(name, module)
|
|
117
|
+
done.append(name)
|
|
118
|
+
return tuple(done)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def unpatch() -> None:
|
|
122
|
+
"""Undo every patch, the last applied first. A client built while patched
|
|
123
|
+
keeps what it was given; proxylib's integrations stay installed, back on
|
|
124
|
+
proxylib's default map."""
|
|
125
|
+
with _lock:
|
|
126
|
+
for name in reversed(list(_undo)):
|
|
127
|
+
for step in reversed(_undo.pop(name)):
|
|
128
|
+
step()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def patched() -> Tuple[str, ...]:
|
|
132
|
+
"""The names patched now, in the order applied."""
|
|
133
|
+
with _lock:
|
|
134
|
+
return tuple(_undo)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _apply(name: str, module: ModuleType) -> None:
|
|
138
|
+
with _lock:
|
|
139
|
+
if name in _undo:
|
|
140
|
+
return
|
|
141
|
+
steps: List[Callable[[], None]] = []
|
|
142
|
+
if _initializing("proxylib") or _initializing("syshttp.proxy"):
|
|
143
|
+
steps.append(_defer(name, module))
|
|
144
|
+
else:
|
|
145
|
+
# Before the TLS patch: syshttp's wrappers then run first and
|
|
146
|
+
# hand proxylib's transports the system context.
|
|
147
|
+
steps.extend(_route(name))
|
|
148
|
+
tls = _TLS[name]
|
|
149
|
+
if tls is not None:
|
|
150
|
+
steps.append(tls(module))
|
|
151
|
+
_undo[name] = steps
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _initializing(name: str) -> bool:
|
|
155
|
+
"""Whether module ``name`` is being imported right now (half built)."""
|
|
156
|
+
module = sys.modules.get(name)
|
|
157
|
+
return module is not None and bool(getattr(getattr(module, "__spec__", None), "_initializing", False))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _context(certificate: bool = False) -> ssl.SSLContext:
|
|
161
|
+
"""The system's context; a fresh one offering no store certificate when
|
|
162
|
+
the caller loads a certificate of its own into it."""
|
|
163
|
+
from . import tls as _tls
|
|
164
|
+
|
|
165
|
+
if certificate:
|
|
166
|
+
return _tls.new_os_context(client_certificates=False)
|
|
167
|
+
return _tls.os_ssl_context()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---- routing -----------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _route(name: str) -> List[Callable[[], None]]:
|
|
174
|
+
"""``proxylib.patch`` with syshttp's map for target ``name``; nothing
|
|
175
|
+
when this proxylib has no such target."""
|
|
176
|
+
import proxylib
|
|
177
|
+
|
|
178
|
+
from . import proxy as _proxy
|
|
179
|
+
|
|
180
|
+
if name not in proxylib.patching.targets():
|
|
181
|
+
_log.debug("proxylib %s has no %r patch target: %s is not routed", proxylib.__version__, name, name)
|
|
182
|
+
return []
|
|
183
|
+
proxymap = _URLErrorMap(_proxy.proxy_map()) if name == "urllib" else _proxy.proxy_map()
|
|
184
|
+
return [proxylib.patch(proxymap, targets=[name]).undo]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Targets whose routing waits for proxylib to finish importing -> their undo
|
|
188
|
+
# steps once routed (None until then).
|
|
189
|
+
_pending: Dict[str, Optional[List[Callable[[], None]]]] = {}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _defer(name: str, module: ModuleType) -> Callable[[], None]:
|
|
193
|
+
"""Route ``name`` once proxylib is importable: at the next patch, or for
|
|
194
|
+
urllib (the only client proxylib imports itself) at the first
|
|
195
|
+
``urlopen``, the call proxylib's urllib integration serves."""
|
|
196
|
+
_pending[name] = None
|
|
197
|
+
undo_opener: Callable[[], None] = lambda: None
|
|
198
|
+
if name == "urllib":
|
|
199
|
+
original = getattr(module, "urlopen")
|
|
200
|
+
|
|
201
|
+
@functools.wraps(original)
|
|
202
|
+
def urlopen(*args: Any, **kwargs: Any) -> Any:
|
|
203
|
+
_flush()
|
|
204
|
+
return original(*args, **kwargs)
|
|
205
|
+
|
|
206
|
+
setattr(module, "urlopen", urlopen)
|
|
207
|
+
undo_opener = lambda: setattr(module, "urlopen", original) # noqa: E731
|
|
208
|
+
|
|
209
|
+
def undo() -> None:
|
|
210
|
+
undo_opener()
|
|
211
|
+
for step in reversed(_pending.pop(name, None) or []):
|
|
212
|
+
step()
|
|
213
|
+
|
|
214
|
+
return undo
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _flush() -> None:
|
|
218
|
+
"""Route every deferred target, once proxylib can be used."""
|
|
219
|
+
if not _pending or _initializing("proxylib") or _initializing("syshttp.proxy"):
|
|
220
|
+
return
|
|
221
|
+
with _lock:
|
|
222
|
+
for name, steps in list(_pending.items()):
|
|
223
|
+
if steps is None:
|
|
224
|
+
_pending[name] = _route(name)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _URLErrorMap:
|
|
228
|
+
"""urllib sends through one proxy: the first reachable one (``Probed``),
|
|
229
|
+
with none reachable reported as urllib reports a connection failure."""
|
|
230
|
+
|
|
231
|
+
def __init__(self, inner: Any) -> None:
|
|
232
|
+
import proxylib
|
|
233
|
+
|
|
234
|
+
self.inner = proxylib.Probed(inner)
|
|
235
|
+
|
|
236
|
+
def __getitem__(self, url: str) -> Any:
|
|
237
|
+
from urllib.error import URLError
|
|
238
|
+
|
|
239
|
+
import proxylib
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
return proxylib.lookup(self.inner, url)
|
|
243
|
+
except proxylib.NoReachableProxyError as exc:
|
|
244
|
+
raise URLError(exc) from exc
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ---- TLS: requests -----------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _tls_requests(requests: ModuleType) -> Callable[[], None]:
|
|
251
|
+
"""Every ``HTTPAdapter`` -- proxylib's included -- verifies an https
|
|
252
|
+
origin with the system's context when ``verify`` is ``True`` and the
|
|
253
|
+
adapter's pool manager was given no context of its own."""
|
|
254
|
+
adapter_class = requests.adapters.HTTPAdapter
|
|
255
|
+
original_key = getattr(adapter_class, "build_connection_pool_key_attributes", None)
|
|
256
|
+
if original_key is None:
|
|
257
|
+
_log.debug("requests %s has no pool-key hook: its own verification stays", requests.__version__)
|
|
258
|
+
return lambda: None
|
|
259
|
+
original_verify = adapter_class.cert_verify
|
|
260
|
+
|
|
261
|
+
def ours(adapter: Any, url: str, verify: Any) -> bool:
|
|
262
|
+
pool = getattr(adapter, "poolmanager", None)
|
|
263
|
+
own = getattr(pool, "connection_pool_kw", {}).get("ssl_context")
|
|
264
|
+
return verify is True and own is None and url.lower().startswith("https")
|
|
265
|
+
|
|
266
|
+
@functools.wraps(original_key)
|
|
267
|
+
def build_connection_pool_key_attributes(self: Any, request: Any, verify: Any, cert: Any = None) -> Any:
|
|
268
|
+
host_params, pool_kwargs = original_key(self, request, verify, cert)
|
|
269
|
+
# requests 2.32.3 puts its own default context (certifi) into the pool
|
|
270
|
+
# for verify=True; later versions leave the key out.
|
|
271
|
+
requests_default = getattr(requests.adapters, "_preloaded_ssl_context", None)
|
|
272
|
+
given = pool_kwargs.get("ssl_context")
|
|
273
|
+
if ours(self, request.url or "", verify) and (given is None or given is requests_default):
|
|
274
|
+
pool_kwargs["ssl_context"] = _context()
|
|
275
|
+
return host_params, pool_kwargs
|
|
276
|
+
|
|
277
|
+
@functools.wraps(original_verify)
|
|
278
|
+
def cert_verify(self: Any, conn: Any, url: str, verify: Any, cert: Any) -> None:
|
|
279
|
+
if ours(self, url, verify):
|
|
280
|
+
# No CA file: urllib3 would load it into the shared system context.
|
|
281
|
+
original_verify(self, conn, url, False, cert)
|
|
282
|
+
conn.cert_reqs = "CERT_REQUIRED"
|
|
283
|
+
return
|
|
284
|
+
original_verify(self, conn, url, verify, cert)
|
|
285
|
+
|
|
286
|
+
setattr(adapter_class, "build_connection_pool_key_attributes", build_connection_pool_key_attributes)
|
|
287
|
+
setattr(adapter_class, "cert_verify", cert_verify)
|
|
288
|
+
|
|
289
|
+
def undo() -> None:
|
|
290
|
+
setattr(adapter_class, "build_connection_pool_key_attributes", original_key)
|
|
291
|
+
setattr(adapter_class, "cert_verify", original_verify)
|
|
292
|
+
|
|
293
|
+
return undo
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---- TLS: urllib -------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
# One context for http.client's default: it sets ALPN on the context it is
|
|
299
|
+
# handed, so it does not share the requests session's.
|
|
300
|
+
_https_context: "Optional[ssl.SSLContext]" = None
|
|
301
|
+
_https_context_at = 0.0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _default_https_context() -> ssl.SSLContext:
|
|
305
|
+
global _https_context, _https_context_at
|
|
306
|
+
from . import tls as _tls
|
|
307
|
+
|
|
308
|
+
with _lock:
|
|
309
|
+
now = time.monotonic()
|
|
310
|
+
if _https_context is None or now - _https_context_at > _tls.OS_CONTEXT_MAX_AGE:
|
|
311
|
+
_https_context = _tls.new_os_context()
|
|
312
|
+
_https_context_at = now
|
|
313
|
+
return _https_context
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _tls_urllib(request: ModuleType) -> Callable[[], None]:
|
|
317
|
+
"""``http.client``'s default HTTPS context (``ssl._create_default_https_context``)
|
|
318
|
+
becomes the system's, for urllib and every other caller that gives none."""
|
|
319
|
+
original = ssl._create_default_https_context # type: ignore[attr-defined]
|
|
320
|
+
setattr(ssl, "_create_default_https_context", _default_https_context)
|
|
321
|
+
return lambda: setattr(ssl, "_create_default_https_context", original)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---- TLS: httpx --------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _tls_httpx(httpx: ModuleType) -> Callable[[], None]:
|
|
328
|
+
"""``httpx.Client`` / ``AsyncClient`` built with ``verify`` left at
|
|
329
|
+
``True`` verify with the system's context (a fresh one offering no store
|
|
330
|
+
certificate when ``cert=`` is given)."""
|
|
331
|
+
|
|
332
|
+
def wrap(client_class: Any) -> Callable[[], None]:
|
|
333
|
+
original = client_class.__init__
|
|
334
|
+
|
|
335
|
+
@functools.wraps(original)
|
|
336
|
+
def __init__(self: Any, *args: Any, **kwargs: Any) -> None:
|
|
337
|
+
if kwargs.get("verify", True) is True:
|
|
338
|
+
kwargs["verify"] = _context(certificate=kwargs.get("cert") is not None)
|
|
339
|
+
original(self, *args, **kwargs)
|
|
340
|
+
|
|
341
|
+
client_class.__init__ = __init__
|
|
342
|
+
return lambda: setattr(client_class, "__init__", original)
|
|
343
|
+
|
|
344
|
+
steps = [wrap(httpx.Client), wrap(httpx.AsyncClient)]
|
|
345
|
+
|
|
346
|
+
def undo() -> None:
|
|
347
|
+
for step in reversed(steps):
|
|
348
|
+
step()
|
|
349
|
+
|
|
350
|
+
return undo
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---- TLS: aiohttp ------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _tls_aiohttp(aiohttp: ModuleType) -> Callable[[], None]:
|
|
357
|
+
"""``ClientSession`` requests with ``ssl`` left at ``True`` and no
|
|
358
|
+
``ssl_context`` / ``verify_ssl`` / ``fingerprint`` verify with the
|
|
359
|
+
system's context."""
|
|
360
|
+
session_class = aiohttp.ClientSession
|
|
361
|
+
original = session_class._request
|
|
362
|
+
|
|
363
|
+
@functools.wraps(original)
|
|
364
|
+
async def _request(self: Any, method: str, str_or_url: Any, **kwargs: Any) -> Any:
|
|
365
|
+
if kwargs.get("ssl", True) is True and all(
|
|
366
|
+
kwargs.get(k) is None for k in ("ssl_context", "verify_ssl", "fingerprint")
|
|
367
|
+
):
|
|
368
|
+
kwargs["ssl"] = _context()
|
|
369
|
+
return await original(self, method, str_or_url, **kwargs)
|
|
370
|
+
|
|
371
|
+
session_class._request = _request
|
|
372
|
+
return lambda: setattr(session_class, "_request", original)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
_TLS: Dict[str, Optional[Callable[[ModuleType], Callable[[], None]]]] = {
|
|
376
|
+
"requests": _tls_requests,
|
|
377
|
+
"urllib": _tls_urllib,
|
|
378
|
+
"httpx": _tls_httpx,
|
|
379
|
+
"aiohttp": _tls_aiohttp,
|
|
380
|
+
# pip verifies with its own truststore context, which is kept.
|
|
381
|
+
"pip": None,
|
|
382
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""An HTTP client on the standard library, with a replaceable transport.
|
|
2
|
+
|
|
3
|
+
`HTTPClient` sends a request through a transport (an object with `send()` and
|
|
4
|
+
`close()`: the default `HTTPTransport` on `http.client`, or one written
|
|
5
|
+
against the `Transport` protocol) and follows redirects under one rule for
|
|
6
|
+
credentials: `Authorization`, `Cookie` and `Proxy-Authorization` go to the
|
|
7
|
+
origin they were given for and to no other scheme, host or port. A reply comes
|
|
8
|
+
back as an `HTTPResponse`.
|
|
9
|
+
|
|
10
|
+
Importing this package loads `http.client` and `ssl`. Nothing here needs a
|
|
11
|
+
third-party library; `requests` in particular is not used.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys as _sys
|
|
17
|
+
import types as _types
|
|
18
|
+
|
|
19
|
+
from . import _settings
|
|
20
|
+
from ._client import DEFAULT_TIMEOUT, HTTPClient
|
|
21
|
+
from ._context import CA_BUNDLE
|
|
22
|
+
from ._default import DEFAULT_TRANSPORT
|
|
23
|
+
from ._fetch import fetch
|
|
24
|
+
from ._follow import is_redirect, split_userinfo
|
|
25
|
+
from ._headers import Headers, peer_text
|
|
26
|
+
from ._prepare import PreparedRequest, prepare_request
|
|
27
|
+
from ._response import HTTPResponse
|
|
28
|
+
from ._state import COOKIE_HOSTS
|
|
29
|
+
from ._status import HTTPStatusError, drop_body, json_body, raise_for_status, read_limited
|
|
30
|
+
from ._transport import HTTPTransport, Transport
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CA_BUNDLE",
|
|
34
|
+
"COOKIE_HOSTS",
|
|
35
|
+
"DEFAULT_TIMEOUT",
|
|
36
|
+
"DEFAULT_TRANSPORT",
|
|
37
|
+
"HTTPClient",
|
|
38
|
+
"HTTPResponse",
|
|
39
|
+
"HTTPStatusError",
|
|
40
|
+
"HTTPTransport",
|
|
41
|
+
"Headers",
|
|
42
|
+
"MAX_REPLY_BYTES",
|
|
43
|
+
"PreparedRequest",
|
|
44
|
+
"Transport",
|
|
45
|
+
"drop_body",
|
|
46
|
+
"fetch",
|
|
47
|
+
"is_redirect",
|
|
48
|
+
"json_body",
|
|
49
|
+
"peer_text",
|
|
50
|
+
"prepare_request",
|
|
51
|
+
"raise_for_status",
|
|
52
|
+
"read_limited",
|
|
53
|
+
"split_userinfo",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _Module(_types.ModuleType):
|
|
58
|
+
"""The module, with `MAX_REPLY_BYTES` read from and written to where it
|
|
59
|
+
takes effect."""
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def MAX_REPLY_BYTES(self) -> int:
|
|
63
|
+
return _settings.MAX_REPLY_BYTES
|
|
64
|
+
|
|
65
|
+
@MAX_REPLY_BYTES.setter
|
|
66
|
+
def MAX_REPLY_BYTES(self, value: int) -> None:
|
|
67
|
+
_settings.MAX_REPLY_BYTES = value
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_sys.modules[__name__].__class__ = _Module
|
syshttp/client/_body.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""How a request body is framed: the length it is sent under, and a body that
|
|
2
|
+
sends exactly that many bytes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import errno as _errno
|
|
7
|
+
import os as _os
|
|
8
|
+
import stat as _stat
|
|
9
|
+
import typing as _ty
|
|
10
|
+
|
|
11
|
+
_STEP = 65536
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def body_length(body) -> "int | None":
|
|
15
|
+
"""The bytes `body` will send from now on, when that is known without
|
|
16
|
+
reading it, else `None`: a bytes-like object, a list or tuple of bytes, a
|
|
17
|
+
file that is seekable (from `tell()` to its end, the position restored) or
|
|
18
|
+
is a regular file (its size less `tell()`). An iterator, a generator and a
|
|
19
|
+
stream that cannot report its size have no length."""
|
|
20
|
+
if body is None:
|
|
21
|
+
return 0
|
|
22
|
+
if isinstance(body, (bytes, bytearray, memoryview)):
|
|
23
|
+
return memoryview(body).nbytes
|
|
24
|
+
if isinstance(body, (list, tuple)):
|
|
25
|
+
if all(isinstance(piece, (bytes, bytearray)) for piece in body):
|
|
26
|
+
return sum(len(piece) for piece in body)
|
|
27
|
+
return None
|
|
28
|
+
if hasattr(body, "read"):
|
|
29
|
+
return _file_length(body)
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _file_length(file) -> "int | None":
|
|
34
|
+
try:
|
|
35
|
+
seekable = file.seekable()
|
|
36
|
+
except (AttributeError, ValueError, OSError):
|
|
37
|
+
seekable = False
|
|
38
|
+
if seekable:
|
|
39
|
+
here = file.tell()
|
|
40
|
+
try:
|
|
41
|
+
end = file.seek(0, 2)
|
|
42
|
+
if end is None:
|
|
43
|
+
end = file.tell()
|
|
44
|
+
finally:
|
|
45
|
+
file.seek(here)
|
|
46
|
+
return max(0, end - here)
|
|
47
|
+
try:
|
|
48
|
+
info = _os.fstat(file.fileno())
|
|
49
|
+
if _stat.S_ISREG(info.st_mode):
|
|
50
|
+
return max(0, info.st_size - file.tell())
|
|
51
|
+
except (AttributeError, ValueError, OSError):
|
|
52
|
+
pass
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def declared_length(pairs: "_ty.Iterable[tuple[str, str]]") -> "int | None":
|
|
57
|
+
"""The `Content-Length` the caller set, or `None`; `ValueError` for one
|
|
58
|
+
that is repeated or is not a count of bytes."""
|
|
59
|
+
found = [value for name, value in pairs if name.lower() == "content-length"]
|
|
60
|
+
if not found:
|
|
61
|
+
return None
|
|
62
|
+
value = found[0].strip()
|
|
63
|
+
if len(found) > 1 or not (value.isascii() and value.isdigit()):
|
|
64
|
+
raise ValueError("the Content-Length header is not one count of bytes")
|
|
65
|
+
return int(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Sized:
|
|
69
|
+
"""A body sent under a `Content-Length`: exactly `length` bytes of `source`
|
|
70
|
+
(a binary file or an iterable of bytes). It never reads or sends more, so a
|
|
71
|
+
file that grew since it was measured cannot overrun the length, and a
|
|
72
|
+
source that ends first is `OSError(EIO)`, not a shorter upload."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, source, length: int):
|
|
75
|
+
self._length = length
|
|
76
|
+
self._left = length
|
|
77
|
+
if hasattr(source, "read"):
|
|
78
|
+
self._read = source.read
|
|
79
|
+
else:
|
|
80
|
+
self._pieces = iter(source)
|
|
81
|
+
self._pending = b""
|
|
82
|
+
self._read = self._read_pieces
|
|
83
|
+
|
|
84
|
+
def __len__(self) -> int:
|
|
85
|
+
return self._length
|
|
86
|
+
|
|
87
|
+
def __iter__(self) -> "_ty.Iterator[bytes]":
|
|
88
|
+
while True:
|
|
89
|
+
chunk = self.read(_STEP)
|
|
90
|
+
if not chunk:
|
|
91
|
+
return
|
|
92
|
+
yield chunk
|
|
93
|
+
|
|
94
|
+
def read(self, amount: "int | None" = -1) -> bytes:
|
|
95
|
+
if self._left <= 0:
|
|
96
|
+
return b""
|
|
97
|
+
want = self._left if amount is None or amount < 0 else min(amount, self._left)
|
|
98
|
+
chunk = self._read(want)
|
|
99
|
+
if isinstance(chunk, str):
|
|
100
|
+
raise TypeError("the request body is bytes, not text")
|
|
101
|
+
if not chunk:
|
|
102
|
+
raise OSError(_errno.EIO, "the request body ended before its Content-Length")
|
|
103
|
+
chunk = bytes(chunk[:want])
|
|
104
|
+
self._left -= len(chunk)
|
|
105
|
+
return chunk
|
|
106
|
+
|
|
107
|
+
def _read_pieces(self, want: int) -> bytes:
|
|
108
|
+
while not self._pending:
|
|
109
|
+
try:
|
|
110
|
+
self._pending = bytes(next(self._pieces))
|
|
111
|
+
except StopIteration:
|
|
112
|
+
return b""
|
|
113
|
+
chunk, self._pending = self._pending[:want], self._pending[want:]
|
|
114
|
+
return chunk
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _chunks(file) -> "_ty.Iterator[bytes]":
|
|
118
|
+
while True:
|
|
119
|
+
chunk = file.read(_STEP)
|
|
120
|
+
if not chunk:
|
|
121
|
+
return
|
|
122
|
+
yield chunk
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def prepare_body(body, pairs: "list[tuple[str, str]]"):
|
|
126
|
+
"""`(value, length)`: what to hand the HTTP layer as the body, and the
|
|
127
|
+
`Content-Length` to add (`None` when the request already names one, the
|
|
128
|
+
layer adds its own, or none is needed). A length the caller set is
|
|
129
|
+
trusted, so nothing is chunked and exactly that many bytes are sent;
|
|
130
|
+
otherwise a body whose length is known is sent under it and any other is
|
|
131
|
+
left for the layer to send chunked."""
|
|
132
|
+
if body is None:
|
|
133
|
+
return None, None
|
|
134
|
+
declared = declared_length(pairs)
|
|
135
|
+
known = body_length(body)
|
|
136
|
+
if isinstance(body, (bytes, bytearray, memoryview)):
|
|
137
|
+
if declared is None or declared == known:
|
|
138
|
+
return body, None
|
|
139
|
+
if known is not None and declared > known:
|
|
140
|
+
raise ValueError("the Content-Length header is larger than the body")
|
|
141
|
+
return bytes(memoryview(body)[:declared]), None
|
|
142
|
+
length: "int | None"
|
|
143
|
+
add: "int | None"
|
|
144
|
+
if declared is not None:
|
|
145
|
+
if known is not None and declared > known:
|
|
146
|
+
raise ValueError("the Content-Length header is larger than the body")
|
|
147
|
+
length, add = declared, None
|
|
148
|
+
elif any(name.lower() == "transfer-encoding" for name, _ in pairs):
|
|
149
|
+
return body, None # the caller frames it
|
|
150
|
+
else:
|
|
151
|
+
length, add = known, known
|
|
152
|
+
if length is None:
|
|
153
|
+
return (_chunks(body) if hasattr(body, "read") else body), None
|
|
154
|
+
if length == 0:
|
|
155
|
+
return b"", add
|
|
156
|
+
return Sized(body, length), add
|