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.
@@ -0,0 +1,550 @@
1
+ """The default transport: HTTP/1.1 on `http.client`, with no third-party code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno as _errno
6
+ import http.client as _client
7
+ import logging as _logging
8
+ import os as _os
9
+ import select as _select
10
+ import socket as _socket
11
+ import ssl as _ssl
12
+ import threading as _threading
13
+ import time as _time
14
+ import typing as _ty
15
+
16
+ import proxylib
17
+
18
+ from .. import proxy as _proxy
19
+ from ..env import HTTPEnv
20
+ from . import _pool, _route
21
+ from ._body import prepare_body
22
+ from ._context import TLSChoice
23
+ from ._headers import (
24
+ Target,
25
+ bracket,
26
+ check_body,
27
+ check_headers,
28
+ check_method,
29
+ check_timeout,
30
+ check_url,
31
+ wants_decoding,
32
+ )
33
+ from ._response import HTTPResponse
34
+ from ._tlstunnel import TunneledTLS
35
+
36
+ _log = _logging.getLogger(__name__)
37
+
38
+
39
+ @_ty.runtime_checkable
40
+ class Transport(_ty.Protocol):
41
+ """What `HTTPClient` sends through: one request, one response, no redirect,
42
+ no credentials added.
43
+
44
+ `send()` takes the arguments `prepare_request()` checks and returns an
45
+ `HTTPResponse` (`stream=False`: its body already read; `stream=True`: the
46
+ caller closes it); `close()` releases whatever the transport holds. The
47
+ client does the redirects, cookies, tokens, hooks, retries and deadline
48
+ above it. Routing (the proxy map, `CONNECT`, gateways), TLS verification,
49
+ connection reuse and decoding a `gzip` or `deflate` body are the
50
+ transport's: `HTTPTransport` does them with syshttp's proxy map and the
51
+ system's TLS, and a transport on another library brings that library's."""
52
+
53
+ def send(
54
+ self,
55
+ method: str,
56
+ url: str,
57
+ *,
58
+ headers: "_ty.Mapping[str, str]",
59
+ body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
60
+ timeout: "float | tuple | None",
61
+ stream: bool = False,
62
+ ) -> HTTPResponse: ...
63
+
64
+ def close(self) -> None: ...
65
+
66
+
67
+ REUSE_WINDOW = 1.0
68
+ """A request that cannot be sent again uses an idle connection only if the
69
+ connection carried a response within this many seconds; an older one may have
70
+ been dropped by the server, and the request would be lost with it."""
71
+
72
+ _IDEMPOTENT = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE", "PROPFIND"})
73
+
74
+ _now = _time.monotonic
75
+
76
+
77
+ def _failure(error: BaseException) -> BaseException:
78
+ """`error` of the socket, TLS or `http.client` layer as the exception a
79
+ transport raises: `TimeoutError`, `ConnectionError`, `ssl.SSLError`, or
80
+ else `OSError(EIO)`. The text never holds a URL or a header."""
81
+ if isinstance(error, (_socket.timeout, TimeoutError)):
82
+ return TimeoutError(_errno.ETIMEDOUT, "the request timed out")
83
+ if isinstance(error, _ssl.SSLError):
84
+ if "timed out" in str(error):
85
+ return TimeoutError(_errno.ETIMEDOUT, "the request timed out")
86
+ return error
87
+ if isinstance(error, _client.RemoteDisconnected):
88
+ return ConnectionResetError(_errno.ECONNRESET, "the server closed the connection without a response")
89
+ if isinstance(error, ConnectionError):
90
+ return error
91
+ if isinstance(error, _socket.gaierror):
92
+ return ConnectionError(_errno.EHOSTUNREACH, "the host name could not be resolved")
93
+ if isinstance(error, _client.NotConnected):
94
+ return ConnectionAbortedError(_errno.ECONNABORTED, "the connection was closed")
95
+ if isinstance(error, _client.IncompleteRead):
96
+ return OSError(_errno.EIO, "the response ended before its stated length")
97
+ if isinstance(error, _client.HTTPException):
98
+ return OSError(_errno.EIO, f"the response is not valid HTTP ({type(error).__name__})")
99
+ return error
100
+
101
+
102
+ def _dropped(error: BaseException) -> bool:
103
+ """Whether `error` says the server closed an idle connection (as opposed to
104
+ a request that went wrong): nothing of a response had arrived."""
105
+ return isinstance(
106
+ error,
107
+ (
108
+ ConnectionResetError,
109
+ ConnectionAbortedError,
110
+ BrokenPipeError,
111
+ _ssl.SSLEOFError,
112
+ _ssl.SSLZeroReturnError,
113
+ ),
114
+ )
115
+
116
+
117
+ def _unusable(sock) -> bool:
118
+ """Whether an idle connection must not carry a request: the server closed
119
+ it, or sent bytes nobody asked for, which the next reply would start with."""
120
+ if sock is None:
121
+ return True
122
+ try:
123
+ if getattr(sock, "pending", None) is not None and sock.pending():
124
+ return True
125
+ return bool(_select.select([sock], [], [], 0)[0])
126
+ except (OSError, ValueError):
127
+ return True
128
+
129
+
130
+ MAX_INTERIM_REPLIES = 8
131
+ """Most interim (1xx) replies the transport skips before the reply it returns;
132
+ a server that sends more is not answering."""
133
+
134
+
135
+ def _skip_headers(fp) -> None:
136
+ """Reads the header lines of an interim reply, up to its blank line."""
137
+ for _ in range(100):
138
+ line = fp.readline(65537)
139
+ if len(line) > 65536:
140
+ raise _client.LineTooLong("header line")
141
+ if line in (b"\r\n", b"\n", b""):
142
+ return
143
+ raise _client.HTTPException("got more than 100 headers")
144
+
145
+
146
+ class _Reply(_client.HTTPResponse):
147
+ """A response that goes past every interim (1xx) reply, not only `100`
148
+ (`http.client` takes any other as the final one, and the real reply is then
149
+ read as the answer to the next request), and at most `MAX_INTERIM_REPLIES`
150
+ of them."""
151
+
152
+ _interim = 0
153
+
154
+ def _read_status(self):
155
+ while True:
156
+ version, status, reason = super()._read_status()
157
+ if not 100 <= status < 200:
158
+ return version, status, reason
159
+ self._interim += 1
160
+ if self._interim > MAX_INTERIM_REPLIES:
161
+ raise _client.HTTPException("too many interim replies")
162
+ if status == _client.CONTINUE:
163
+ return version, status, reason # `begin()` skips its headers
164
+ _skip_headers(self.fp)
165
+
166
+
167
+ class _Dropped(Exception):
168
+ """An idle connection the server had closed."""
169
+
170
+ def __init__(self, error: BaseException):
171
+ super().__init__()
172
+ self.error = error
173
+
174
+
175
+ class _Payload:
176
+ """The request body, whether it can be sent twice, and the
177
+ `Content-Length` to add to the request (`None`: none to add)."""
178
+
179
+ def __init__(self, body, pairs):
180
+ self.repeatable = check_body(body)
181
+ self.value, self.length = prepare_body(body, pairs)
182
+
183
+
184
+ class _Body:
185
+ """The body of one response, read from its connection. The connection goes
186
+ back to the pool once the body has been read to its end; one that is
187
+ closed earlier, fails, or ends short is closed."""
188
+
189
+ def __init__(self, owner, pool, connection, response, expected: "int | None"):
190
+ # The transport stays alive while its reply is open: collecting it
191
+ # would close the connection the reply is reading.
192
+ self._owner = owner
193
+ self._pool = pool
194
+ self._connection = connection
195
+ self._response = response
196
+ self._expected = expected
197
+ self._received = 0
198
+ self._done = False
199
+ if expected == 0:
200
+ self._finish()
201
+
202
+ def _finish(self) -> None:
203
+ if self._done:
204
+ return
205
+ self._done = True
206
+ # Closing the response closes its file object, not the socket; a
207
+ # response left open would make `http.client` refuse the next request.
208
+ self._response.close()
209
+ if self._response.will_close:
210
+ self._pool.discard(self._connection)
211
+ else:
212
+ self._pool.give_back(self._connection, _now())
213
+
214
+ def _abort(self) -> None:
215
+ if not self._done:
216
+ self._done = True
217
+ self._response.close()
218
+ self._pool.discard(self._connection)
219
+
220
+ def read(self, amount: int) -> bytes:
221
+ if self._done:
222
+ return b""
223
+ try:
224
+ chunk = self._response.read1(amount)
225
+ except (OSError, _client.HTTPException) as error:
226
+ self._abort()
227
+ raise _failure(error) from None
228
+ except BaseException:
229
+ self._abort()
230
+ raise
231
+ if chunk:
232
+ self._received += len(chunk)
233
+ if self._response.isclosed():
234
+ self._finish()
235
+ return chunk
236
+ if self._expected is not None and self._received < self._expected:
237
+ self._abort()
238
+ raise OSError(
239
+ _errno.EIO,
240
+ f"the response ended after {self._received} of {self._expected} bytes",
241
+ )
242
+ self._finish()
243
+ return b""
244
+
245
+ def close(self) -> None:
246
+ self._abort()
247
+
248
+
249
+ class _Route(_ty.NamedTuple):
250
+ """Where a request is sent and how it is written for the first server it
251
+ reaches."""
252
+
253
+ wire: Target # the server written for: the origin, or a gateway
254
+ proxy: "proxylib.Proxy | None" # the http or https proxy it passes through
255
+ gateway: "proxylib.Proxy | None" # the URL-prefix gateway it is handed to
256
+ key: tuple # what a pooled connection is keyed by
257
+
258
+
259
+ class HTTPTransport:
260
+ """Sends HTTP requests on the standard library: `http.client` over a socket,
261
+ with TLS from `ssl`, and no third-party code.
262
+
263
+ `send()` makes exactly one request: it follows no redirect, adds no
264
+ credentials of the caller's and retries only as described below. A
265
+ transport is a context manager, and `close()` closes every connection it
266
+ holds.
267
+
268
+ **Routing.** Each request asks `proxymap` (a proxylib proxy map; default
269
+ `proxy.proxy_map(env=env)`: `SYSHTTP_PROXY`, the `*_PROXY` variables,
270
+ `NO_PROXY`, then the system's configuration) which entries it may use, and
271
+ takes the first that accepts a connection (`proxylib.Probed`; with no entry
272
+ reachable and no direct one, `proxylib.NoReachableProxyError`). A direct
273
+ entry connects to the origin. An `http:` or `https:` proxy gets an
274
+ absolute-form request for an `http:` origin and a `CONNECT` for an `https:`
275
+ one; its headers (a credential) go to it alone, in the `CONNECT` or on the
276
+ plain-http request, never through the tunnel. A `prefix+http(s)` gateway is
277
+ asked for `<gateway><path><URL>` with its credential as a header of that
278
+ request, and the response keeps the URL asked for. SOCKS entries are not
279
+ spoken (`proxylib.UnsupportedProxyError` when nothing else is offered). A
280
+ map with no opinion for a URL, `{}` included, means a direct connection.
281
+
282
+ **TLS.** `https:` origins are verified against `ssl_context` when given,
283
+ else the CA bundle named by the `CA_BUNDLE` setting, else the system's
284
+ context (`tls.os_ssl_context()`). `verify=False` is the one way to turn
285
+ verification off, `verify=<path>` names a bundle, and an `ssl_context`
286
+ beside either is `ValueError`. An `https:` proxy or gateway is spoken to
287
+ with `proxy_ssl_context`, else the system's context.
288
+
289
+ Each thread keeps one idle connection per route (at most 8), which a later
290
+ request reuses unless the server has closed it or sent bytes on it since;
291
+ the connections of a thread are closed when it ends. A
292
+ request that fails before any response byte on a reused connection because
293
+ the server had closed it is sent once more on a new connection, but only
294
+ if it is repeatable (`GET`, `HEAD`, `OPTIONS`, `PUT`, `DELETE`,
295
+ `PROPFIND`) and its body is `None` or in memory. Any other request reuses a
296
+ connection only if it carried a response within the last second."""
297
+
298
+ def __init__(
299
+ self,
300
+ *,
301
+ proxymap: "_ty.Any" = None,
302
+ env: "HTTPEnv | None" = None,
303
+ ssl_context: "_ssl.SSLContext | None" = None,
304
+ proxy_ssl_context: "_ssl.SSLContext | None" = None,
305
+ verify: "bool | str | _os.PathLike[str]" = True,
306
+ ):
307
+ self._env = HTTPEnv() if env is None else env
308
+ self._tls = TLSChoice(self._env, ssl_context, proxy_ssl_context, verify)
309
+ if proxymap is None:
310
+ # Decided at every request; a malformed setting is refused now.
311
+ _proxy.configured_proxy(env=self._env)
312
+ self._proxymap: "_ty.Any" = _proxy.proxy_map(env=self._env)
313
+ else:
314
+ self._proxymap = _ty.cast(_ty.Any, proxylib.ProxyMap)(proxymap)
315
+ if self._tls.unverified:
316
+ _log.warning("HTTPTransport(verify=False): TLS certificates are not verified")
317
+ self._pool = _pool.Pool()
318
+ self._finalizer = _pool.finalize_with(self, self._pool)
319
+
320
+ def __repr__(self) -> str:
321
+ return f"HTTPTransport(verify={not self._tls.unverified})"
322
+
323
+ def __enter__(self) -> "HTTPTransport":
324
+ return self
325
+
326
+ def __exit__(self, *exc_info) -> None:
327
+ self.close()
328
+
329
+ def close(self) -> None:
330
+ """Closes every connection, idle or in use. Harmless when called again;
331
+ a later request connects afresh."""
332
+ self._pool.close()
333
+
334
+ def send(
335
+ self,
336
+ method: str,
337
+ url: str,
338
+ *,
339
+ headers: "_ty.Mapping[str, str]",
340
+ body: "bytes | _ty.BinaryIO | _ty.Iterable[bytes] | None" = None,
341
+ timeout: "float | tuple | None",
342
+ stream: bool = False,
343
+ ) -> HTTPResponse:
344
+ """One request, one response.
345
+
346
+ `headers` are sent as given; `Accept-Encoding: gzip, deflate` is added
347
+ (and a coded body decoded) unless they name an `Accept-Encoding`.
348
+ A `body` whose length is known without reading it goes under a
349
+ `Content-Length`, any other chunked; a `Content-Length` in `headers`
350
+ is used as it is. `timeout` is seconds for the connection and for each wait on the
351
+ server: a number, a `(connect, read)` pair, or `None` to wait for ever.
352
+ With `stream=False` the body has been read when this returns (a body
353
+ that decodes to more than `MAX_REPLY_BYTES` is `OSError(EFBIG)`); with
354
+ `stream=True` it is read from the response, which must be closed.
355
+
356
+ Raises `ValueError` before anything is sent for a header or URL with
357
+ a control character, a URL with credentials or another scheme;
358
+ `TimeoutError`, `ConnectionError` and `ssl.SSLError` for the network;
359
+ `OSError(EIO)` for a reply that is not valid HTTP or ends early. The
360
+ route adds `proxylib.NoReachableProxyError`,
361
+ `proxylib.UnsupportedProxyError` and, for a setting that cannot be used,
362
+ `ConfigurationError`."""
363
+ method = check_method(method)
364
+ target = check_url(url)
365
+ pairs = check_headers(headers)
366
+ connect_timeout, read_timeout = check_timeout(timeout)
367
+ payload = _Payload(body, pairs)
368
+ names = {name.lower() for name, _ in pairs}
369
+ decode = wants_decoding(pairs)
370
+ # As the server will see it: no fragment, "/" for an empty path.
371
+ plain_url = f"{target.scheme}://{target.authority}{target.path}"
372
+ scope = _pool.scope_id()
373
+ route = self._route(target, plain_url, connect_timeout, scope)
374
+ response = self._attempt(
375
+ route, method, pairs, names, payload, connect_timeout, read_timeout, plain_url, decode, scope
376
+ )
377
+ return response if stream else response.buffered()
378
+
379
+ def _plan(self, entry: "proxylib.Proxy | None", target: Target, plain_url: str) -> _Route:
380
+ if entry is None:
381
+ return _Route(target, None, None, (target.scheme, target.host, target.port, None))
382
+ if entry.is_gateway:
383
+ wire = check_url(entry.gateway_url(plain_url))
384
+ return _Route(wire, None, entry, ("gateway", wire.scheme, wire.host, wire.port))
385
+ peer = (entry.scheme, entry.host, _route.port(entry))
386
+ return _Route(target, entry, None, (target.scheme, target.host, target.port, peer))
387
+
388
+ def _route(self, target: Target, plain_url: str, connect_timeout, scope: int) -> _Route:
389
+ """The route of a request: the first entry the proxy map answers whose
390
+ proxy accepts a connection. One this thread already holds a connection
391
+ to is known to answer, and is not probed again."""
392
+ entries = _route.entries(self._proxymap, plain_url)
393
+ first = self._plan(entries[0], target, plain_url)
394
+ if entries[0] is None or self._pool.has_idle(first.key, scope):
395
+ return first
396
+ chosen = _route.reachable(entries, plain_url, connect_timeout)
397
+ return first if chosen == entries[0] else self._plan(chosen, target, plain_url)
398
+
399
+ def _attempt(
400
+ self,
401
+ route: _Route,
402
+ method: str,
403
+ pairs,
404
+ names,
405
+ payload: _Payload,
406
+ connect_timeout,
407
+ read_timeout,
408
+ url: str,
409
+ decode: bool,
410
+ scope: int,
411
+ ) -> HTTPResponse:
412
+ repeatable = method in _IDEMPOTENT and payload.repeatable
413
+ connection = self._pool.take(route.key, scope)
414
+ if connection is not None and not repeatable and _now() - connection.released_at > REUSE_WINDOW:
415
+ self._pool.discard(connection)
416
+ connection = None
417
+ if connection is not None and _unusable(connection.http.sock):
418
+ self._pool.discard(connection)
419
+ connection = None
420
+ reused = connection is not None
421
+ while True:
422
+ if connection is None:
423
+ connection = self._open(route, scope, connect_timeout, read_timeout)
424
+ reused = False
425
+ sock = connection.http.sock
426
+ try:
427
+ if sock is None:
428
+ raise ConnectionAbortedError(_errno.ECONNABORTED, "the connection was closed")
429
+ sock.settimeout(read_timeout)
430
+ except OSError:
431
+ # An idle socket that is already closed: nothing was sent.
432
+ self._pool.discard(connection)
433
+ if not reused:
434
+ raise
435
+ connection, reused = None, False
436
+ continue
437
+ try:
438
+ return self._exchange(connection, route, method, pairs, names, payload, url, decode, reused)
439
+ except _Dropped as dropped:
440
+ if reused and repeatable:
441
+ connection, reused = None, False
442
+ continue
443
+ raise dropped.error from None
444
+
445
+ def _headers(self, route: _Route, pairs, names, payload) -> "dict[str, str]":
446
+ sent = dict(pairs)
447
+ if payload.length is not None:
448
+ sent["Content-Length"] = str(payload.length)
449
+ if "host" not in names:
450
+ sent["Host"] = route.wire.authority
451
+ if "accept-encoding" not in names:
452
+ sent["Accept-Encoding"] = "gzip, deflate"
453
+ # What a proxy or gateway is given for itself rides on a request it
454
+ # reads: not on one inside a tunnel.
455
+ owner = route.gateway or (route.proxy if route.wire.scheme == "http" else None)
456
+ if owner is not None:
457
+ for name, value in _route.credentials(owner):
458
+ if name.lower() not in names:
459
+ sent[name] = value
460
+ return sent
461
+
462
+ def _exchange(self, connection, route: _Route, method, pairs, names, payload, url, decode, reused) -> HTTPResponse:
463
+ http = connection.http
464
+ wire = route.wire
465
+ line = f"http://{wire.authority}{wire.path}" if route.proxy is not None and wire.scheme == "http" else wire.path
466
+ try:
467
+ http.request(method, line, body=payload.value, headers=self._headers(route, pairs, names, payload))
468
+ response = http.getresponse()
469
+ except (OSError, _client.HTTPException) as error:
470
+ self._pool.discard(connection)
471
+ if reused and _dropped(error):
472
+ raise _Dropped(_failure(error)) from None
473
+ raise _failure(error) from None
474
+ except BaseException:
475
+ self._pool.discard(connection)
476
+ raise
477
+ headers = response.getheaders()
478
+ body = _Body(self, self._pool, connection, response, response.length)
479
+ return HTTPResponse(response.status, response.reason, headers, url, body, decode=decode)
480
+
481
+ def _open(self, route: _Route, scope: int, connect_timeout, read_timeout):
482
+ proxy, wire = route.proxy, route.wire
483
+ host, port = (proxy.host, _route.port(proxy)) if proxy is not None else (wire.host, wire.port)
484
+ try:
485
+ raw = _socket.create_connection((host, port), timeout=connect_timeout)
486
+ except (OSError, _client.HTTPException) as error:
487
+ raise _failure(error) from None
488
+ sock: _ty.Any = raw
489
+ try:
490
+ raw.setsockopt(_socket.IPPROTO_TCP, _socket.TCP_NODELAY, 1)
491
+ sock = self._layers(raw, route)
492
+ sock.settimeout(read_timeout)
493
+ except (OSError, _client.HTTPException) as error:
494
+ sock.close()
495
+ raise _failure(error) from None
496
+ except BaseException:
497
+ sock.close()
498
+ raise
499
+ http = _client.HTTPConnection(wire.host, wire.port)
500
+ # A connection that was closed stays closed: `http.client` would
501
+ # otherwise reconnect by itself, in plain TCP, outside the TLS session,
502
+ # the proxy and the timeout this request was given.
503
+ http.auto_open = 0
504
+ http.response_class = _Reply
505
+ http.sock = sock
506
+ connection = _pool.Connection(http, route.key, scope, self._pool.generation)
507
+ self._pool.register(connection)
508
+ return connection
509
+
510
+ def _layers(self, raw, route: _Route):
511
+ """`raw`, connected to the first server of `route`, with the tunnel and
512
+ the TLS the route needs on it. What it made is closed when a step fails."""
513
+ proxy, wire = route.proxy, route.wire
514
+ sock: _ty.Any = raw
515
+ try:
516
+ if proxy is not None and proxy.scheme == "https":
517
+ sock = self._tls.proxy().wrap_socket(raw, server_hostname=proxy.host)
518
+ if proxy is not None and wire.scheme == "https":
519
+ self._tunnel(sock, wire, proxy)
520
+ if proxy.scheme == "https":
521
+ # The origin's TLS cannot be wrapped around another TLS socket.
522
+ sock = TunneledTLS(sock, self._tls.origin(), wire.host)
523
+ else:
524
+ sock = self._tls.origin().wrap_socket(sock, server_hostname=wire.host)
525
+ elif route.gateway is not None:
526
+ if wire.scheme == "https":
527
+ sock = self._tls.proxy().wrap_socket(raw, server_hostname=wire.host)
528
+ elif wire.scheme == "https":
529
+ sock = self._tls.origin().wrap_socket(raw, server_hostname=wire.host)
530
+ return sock
531
+ except BaseException:
532
+ sock.close()
533
+ raise
534
+
535
+ @staticmethod
536
+ def _tunnel(sock, target: Target, proxy: "proxylib.Proxy") -> None:
537
+ """Asks the proxy to connect `sock` to the server (RFC 9110 9.3.6). Only
538
+ what the proxy is configured with goes in the request."""
539
+ authority = f"{bracket(target.host)}:{target.port}"
540
+ lines = [f"CONNECT {authority} HTTP/1.1", f"Host: {authority}"]
541
+ lines += [f"{name}: {value}" for name, value in _route.credentials(proxy)]
542
+ sock.sendall(("\r\n".join(lines) + "\r\n\r\n").encode("ascii"))
543
+ reply = _client.HTTPResponse(sock, method="CONNECT")
544
+ try:
545
+ reply.begin()
546
+ status = reply.status
547
+ finally:
548
+ reply.close()
549
+ if not 200 <= status < 300:
550
+ raise ConnectionRefusedError(_errno.ECONNREFUSED, f"the proxy refused CONNECT (HTTP {status})")