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,322 @@
1
+ """What a client remembers between requests: the cookies of every host it talked
2
+ to, kept in memory and, with a jar, on disk by host; and the tokens that become
3
+ `Authorization` headers.
4
+
5
+ Both are chosen by the host of the URL being requested -- the origin -- never
6
+ by a proxy's or a gateway's: they sit above the transport."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses as _dataclasses
11
+ import ipaddress as _ipaddress
12
+ import re as _re
13
+ import threading as _threading
14
+ import time as _time
15
+ import typing as _ty
16
+ import urllib.parse as _urlparse
17
+
18
+ from ..cookies import (
19
+ _SECURE_HOSTS,
20
+ CookieSpec,
21
+ _path_matches,
22
+ cookie_applies,
23
+ domain_covers,
24
+ host_aliases,
25
+ set_cookie_specs,
26
+ )
27
+ from ..env import HTTPEnv
28
+ from ..exceptions import ConfigurationError
29
+ from ..jar import CookieJar, FileCookieJar, FileTokenJar, TokenJar
30
+ from ._headers import has_header
31
+
32
+ COOKIE_HOSTS = "SYSHTTP_COOKIE_HOSTS"
33
+ """The setting declaring hosts that share cookies: hosts split by `,`, groups
34
+ by `;`."""
35
+
36
+ BY_HOST = "__by_host__"
37
+ """The jar key that means: the origin's host, its alias group's first host."""
38
+
39
+
40
+ def cookie_aliases(cookie_hosts: "_ty.Iterable[_ty.Iterable[str]] | None", env: HTTPEnv) -> "dict[str, str]":
41
+ """`cookie_hosts`, else `SYSHTTP_COOKIE_HOSTS`, as `cookies.host_aliases`
42
+ maps them."""
43
+ if cookie_hosts is not None:
44
+ return host_aliases(cookie_hosts)
45
+ found = env.find(COOKIE_HOSTS)
46
+ if not found:
47
+ return {}
48
+ variable, raw = found
49
+ groups = [[h for h in group.split(",") if h.strip()] for group in raw.split(";")]
50
+ try:
51
+ return host_aliases(groups)
52
+ except ValueError as exc:
53
+ raise ConfigurationError("%s: %s" % (variable, exc)) from None
54
+
55
+
56
+ def _is_ip(host: str) -> bool:
57
+ try:
58
+ _ipaddress.ip_address(host)
59
+ except ValueError:
60
+ return False
61
+ return True
62
+
63
+
64
+ def _with_host(url: str, host: str) -> str:
65
+ """`url` with its host replaced by `host`, port kept."""
66
+ parts = _urlparse.urlsplit(url)
67
+ netloc = "[%s]" % host if ":" in host else host
68
+ try:
69
+ port = parts.port
70
+ except ValueError:
71
+ port = None
72
+ if port is not None:
73
+ netloc += ":%d" % port
74
+ return _urlparse.urlunsplit(parts._replace(netloc=netloc))
75
+
76
+
77
+ class Cookies:
78
+ """The cookies of one client.
79
+
80
+ Rows are `CookieSpec`s, set from `Set-Cookie` as RFC 6265 reads it
81
+ (`set_cookie_specs`) and sent as `cookie_applies` decides. With a `jar`, the
82
+ rows of a host are loaded from it the first time that host is asked for,
83
+ and written back only when they differ from what was loaded or written
84
+ last. `key` is `BY_HOST` (one entry per origin host) or one name for every
85
+ host. `aliases` (`host_aliases`) makes groups of hosts one."""
86
+
87
+ def __init__(self, jar: "CookieJar | None", key: str, aliases: "_ty.Mapping[str, str]"):
88
+ self._jar = jar
89
+ self._key = key
90
+ self._aliases = dict(aliases)
91
+ self._group_of: "dict[str, list[str]]" = {}
92
+ for alias, first in self._aliases.items():
93
+ self._group_of.setdefault(first, []).append(alias)
94
+ self._rows: "dict[tuple[str, str, str], CookieSpec]" = {}
95
+ self._loaded: "set[str]" = set()
96
+ self._synced: "dict[str, frozenset[CookieSpec]]" = {}
97
+ self._lock = _threading.RLock()
98
+
99
+ # -- which hosts and keys a URL means
100
+
101
+ def _host(self, url: str) -> str:
102
+ """The host cookies of `url` are kept under: its alias group's first."""
103
+ host = _urlparse.urlsplit(url).hostname or ""
104
+ return self._aliases.get(host.lower(), host)
105
+
106
+ def _keys(self, url: str) -> "list[str]":
107
+ if self._key != BY_HOST:
108
+ return [self._key]
109
+ host = self._host(url)
110
+ return [host] if host else []
111
+
112
+ def _urls(self, url: str) -> "list[str]":
113
+ """`url`, and the same URL for every other host of its alias group."""
114
+ host = (_urlparse.urlsplit(url).hostname or "").lower()
115
+ first = self._aliases.get(host)
116
+ if first is None:
117
+ return [url]
118
+ return [url] + [_with_host(url, member) for member in self._group_of[first] if member != host]
119
+
120
+ # -- the rows
121
+
122
+ def _load(self, rows: "_ty.Iterable[CookieSpec]", hosts: "_ty.Sequence[str] | None" = None) -> None:
123
+ """Keeps `rows`; with `hosts`, only the ones whose domain covers one of
124
+ them, so a file that holds a row for another domain (planted, or
125
+ copied) never sends it from this host."""
126
+ for row in rows:
127
+ bare = row.domain.lstrip(".").lower()
128
+ if not bare:
129
+ continue
130
+ # An IP address matches only itself, whatever the flag says.
131
+ subdomains = bool((row.subdomains or row.domain.startswith(".")) and not _is_ip(bare))
132
+ spec = _dataclasses.replace(
133
+ row, domain=bare, subdomains=subdomains, path=row.path or "/", expires=row.expires or None
134
+ )
135
+ if hosts is not None and not any(self._covers(spec, host) for host in hosts):
136
+ continue
137
+ self._rows[(bare, spec.path, spec.name)] = spec
138
+
139
+ @staticmethod
140
+ def _covers(row: CookieSpec, host: str) -> bool:
141
+ # A row's domain is bare; its include-subdomains flag is the dot.
142
+ return domain_covers("." + row.domain if row.subdomains else row.domain, host)
143
+
144
+ def _rows_for(self, key: str, host: str) -> "list[CookieSpec]":
145
+ """The rows written under `key`: a by-host key keeps only the rows
146
+ whose domain covers its host (or group), so a client that talked to two
147
+ hosts never copies one's cookies into the other's file; a named key keeps
148
+ everything."""
149
+ rows = list(self._rows.values())
150
+ if key != host:
151
+ return rows
152
+ hosts = self._group_of.get(key, [key])
153
+ return [row for row in rows if any(self._covers(row, h) for h in hosts)]
154
+
155
+ # -- what a hop does
156
+
157
+ def before(self, url: str) -> None:
158
+ """Loads the jar's rows for the keys of `url` that were not yet loaded."""
159
+ if self._jar is None:
160
+ return
161
+ with self._lock:
162
+ for key in self._keys(url):
163
+ if key in self._loaded:
164
+ continue
165
+ host = self._host(url)
166
+ try:
167
+ # A by-host file holds its host's (or group's) cookies only.
168
+ covered = self._group_of.get(key, [key]) if self._key == BY_HOST else None
169
+ self._load(self._jar.get(key, []), covered)
170
+ except Exception:
171
+ pass # an unreadable jar is an empty one
172
+ self._synced[key] = frozenset(self._rows_for(key, host))
173
+ self._loaded.add(key)
174
+
175
+ def header(self, url: str, now: "float | None" = None) -> "str | None":
176
+ """The `Cookie` header for a request to `url`, or `None`. Longer paths
177
+ come first (RFC 6265 5.4)."""
178
+ moment = _time.time() if now is None else now
179
+ urls = self._urls(url)
180
+ with self._lock:
181
+ chosen = [row for row in self._rows.values() if any(cookie_applies(row, u, moment) for u in urls)]
182
+ chosen.sort(key=lambda row: -len(row.path))
183
+ return "; ".join("%s=%s" % (row.name, row.value) for row in chosen) or None
184
+
185
+ def store(self, url: str, values: "_ty.Iterable[str]") -> None:
186
+ """Keeps the cookies of `Set-Cookie` `values` a response from `url` sent,
187
+ deletes the ones it expires, and writes the jar when rows changed."""
188
+ parts = _urlparse.urlsplit(url)
189
+ found = set_cookie_specs(values, parts.hostname or "", request_path=parts.path or "/", aliases=self._aliases)
190
+ secure = parts.scheme == "https" or (parts.hostname or "").lower() in _SECURE_HOSTS
191
+ with self._lock:
192
+ for spec, gone in found:
193
+ if not secure and (spec.secure or self._shadows_secure(spec)):
194
+ continue # a plain-http response cannot set, replace or delete a Secure cookie
195
+ key = (spec.domain, spec.path, spec.name)
196
+ if gone:
197
+ self._rows.pop(key, None)
198
+ else:
199
+ self._rows[key] = spec
200
+ self._write(url)
201
+
202
+ def _shadows_secure(self, spec: CookieSpec) -> bool:
203
+ """Whether a stored Secure cookie has `spec`'s name, a domain that
204
+ matches its domain or the other way round, and a path `spec`'s path
205
+ matches (RFC 6265bis 5.7, leave Secure cookies alone)."""
206
+ return any(
207
+ row.secure
208
+ and row.name == spec.name
209
+ and (
210
+ row.domain == spec.domain
211
+ or row.domain.endswith("." + spec.domain)
212
+ or spec.domain.endswith("." + row.domain)
213
+ )
214
+ and _path_matches(row.path, spec.path)
215
+ for row in self._rows.values()
216
+ )
217
+
218
+ def _write(self, url: str) -> None:
219
+ if self._jar is None:
220
+ return
221
+ host = self._host(url)
222
+ for key in self._keys(url):
223
+ rows = self._rows_for(key, host)
224
+ state = frozenset(rows)
225
+ if self._synced.get(key) == state:
226
+ continue
227
+ try:
228
+ self._jar[key] = rows
229
+ except Exception:
230
+ continue
231
+ self._synced[key] = state
232
+
233
+
234
+ class Tokens:
235
+ """The tokens of one client: `Authorization` values chosen by the origin's
236
+ host, or one key for every host. A secret: never logged or put in a
237
+ message."""
238
+
239
+ def __init__(self, jar: TokenJar, key: str):
240
+ self._jar = jar
241
+ self._key = key
242
+
243
+ def authorization(self, url: str) -> "str | None":
244
+ """The `Authorization` value for `url`'s host, or `None`. A token that
245
+ already names its scheme (`Basic ...`, `token ...`) is sent verbatim; a
246
+ bare one is a bearer token. One with a CR, LF or NUL is a
247
+ `ConfigurationError` naming the key, never the value."""
248
+ key = self._key if self._key != BY_HOST else (_urlparse.urlsplit(url).hostname or "")
249
+ if not key:
250
+ return None
251
+ try:
252
+ token = self._jar.get(key)
253
+ except Exception:
254
+ return None
255
+ if not token:
256
+ return None
257
+ token = str(token).strip()
258
+ if any(ch in token for ch in "\r\n\0"):
259
+ raise ConfigurationError("the token for %r contains a carriage return, line feed or NUL character" % key)
260
+ return token if _re.match(r"^[A-Za-z][\w-]*\s+\S", token) else "Bearer " + token
261
+
262
+
263
+ def make_cookies(cookies: "bool | str | CookieJar | None", env: HTTPEnv, aliases: "_ty.Mapping[str, str]") -> Cookies:
264
+ """The `cookies=` argument of a client as its `Cookies`: `True` is a file
265
+ jar under the state directory keyed by host, a string one key for every
266
+ host, a jar object is used as given, `False` or `None` keeps cookies in
267
+ memory only."""
268
+ if cookies is True:
269
+ return Cookies(FileCookieJar(env=env), BY_HOST, aliases)
270
+ if isinstance(cookies, str):
271
+ return Cookies(FileCookieJar(env=env), cookies, aliases)
272
+ if cookies is None or cookies is False:
273
+ return Cookies(None, BY_HOST, aliases)
274
+ return Cookies(cookies, BY_HOST, aliases) # `is not False`, not truthiness: an empty jar is a jar
275
+
276
+
277
+ def make_tokens(tokens: "bool | str | TokenJar | None", env: HTTPEnv) -> "Tokens | None":
278
+ """The `tokens=` argument of a client as its `Tokens`, read as `cookies=`
279
+ is; `None` for no tokens."""
280
+ if tokens is True:
281
+ return Tokens(FileTokenJar(env=env), BY_HOST)
282
+ if isinstance(tokens, str):
283
+ return Tokens(FileTokenJar(env=env), tokens)
284
+ if tokens is None or tokens is False:
285
+ return None
286
+ return Tokens(tokens, BY_HOST)
287
+
288
+
289
+ class StateTransport:
290
+ """A transport as the client sends through it: the cookies for each URL go
291
+ with the request and the ones the response sets are kept. The transport it
292
+ wraps stays the caller's."""
293
+
294
+ def __init__(self, transport: _ty.Any, cookies: Cookies):
295
+ self._transport = transport
296
+ self._cookies = cookies
297
+
298
+ def send(
299
+ self,
300
+ method: str,
301
+ url: str,
302
+ *,
303
+ headers: "_ty.Mapping[str, str]",
304
+ body: _ty.Any = None,
305
+ timeout: _ty.Any,
306
+ stream: bool = False,
307
+ ) -> _ty.Any:
308
+ sent = headers
309
+ self._cookies.before(url)
310
+ if not has_header(headers, "Cookie"):
311
+ cookie = self._cookies.header(url)
312
+ if cookie is not None:
313
+ sent = dict(headers)
314
+ sent["Cookie"] = cookie
315
+ response = self._transport.send(method, url, headers=sent, body=body, timeout=timeout, stream=stream)
316
+ values = response.headers.get_all("Set-Cookie")
317
+ if values:
318
+ self._cookies.store(url, values)
319
+ return response
320
+
321
+ def close(self) -> None:
322
+ """Nothing of its own: the transport it wraps is closed by whoever made it."""
@@ -0,0 +1,59 @@
1
+ """The status of a reply, and how much of its body the library reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json as _json
6
+
7
+ from . import _settings
8
+ from ._response import CHUNK, HTTPResponse, too_large
9
+
10
+
11
+ class HTTPStatusError(Exception):
12
+ """A reply with an error status (4xx or 5xx). `response` is the reply."""
13
+
14
+ response: HTTPResponse
15
+
16
+ def __init__(self, response: HTTPResponse):
17
+ super().__init__(response.status)
18
+ self.response = response
19
+
20
+
21
+ def raise_for_status(resp: HTTPResponse) -> None:
22
+ """Raises `HTTPStatusError` for a reply with status 400 or above."""
23
+ if resp.status >= 400:
24
+ raise HTTPStatusError(resp)
25
+
26
+
27
+ def read_limited(resp: HTTPResponse, what: object, limit: "int | None" = None) -> bytes:
28
+ """The decoded body of `resp`, which must be at most `limit` bytes (default
29
+ `MAX_REPLY_BYTES`): `OSError(EFBIG)` naming `what` otherwise, with the
30
+ connection closed."""
31
+ limit = _settings.MAX_REPLY_BYTES if limit is None else limit
32
+ resp._buffered(limit, too_large(what, limit))
33
+ return resp.read()
34
+
35
+
36
+ def drop_body(resp: HTTPResponse, limit: "int | None" = None) -> None:
37
+ """Gives up the body of a reply nobody looks at: it is read, up to `limit`
38
+ bytes (default `MAX_REPLY_BYTES`), so the connection can serve the next
39
+ request, and `resp` is closed (which closes the connection when the body
40
+ was longer). A failure while reading it is not the caller's concern: the
41
+ status was already used."""
42
+ remaining = _settings.MAX_REPLY_BYTES if limit is None else limit
43
+ try:
44
+ while remaining > 0:
45
+ chunk = resp.read(min(CHUNK, remaining))
46
+ if not chunk:
47
+ break
48
+ remaining -= len(chunk)
49
+ except (OSError, ValueError):
50
+ pass
51
+ finally:
52
+ resp.close()
53
+
54
+
55
+ def json_body(resp: HTTPResponse, what: object, limit: "int | None" = None):
56
+ """The JSON document in the body of `resp` (at most `limit` bytes, default
57
+ `MAX_REPLY_BYTES`, else `OSError(EFBIG)` naming `what`); `ValueError` when
58
+ it is not one."""
59
+ return _json.loads(read_limited(resp, what, limit))
@@ -0,0 +1,114 @@
1
+ """TLS inside TLS: an https origin reached through an https proxy.
2
+
3
+ `ssl` wraps a plain socket only, so the inner session runs on memory buffers
4
+ and this object moves its bytes over the already encrypted connection to the
5
+ proxy."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import io as _io
10
+ import socket as _socket
11
+ import ssl as _ssl
12
+ import typing as _ty
13
+
14
+ _BLOCK = 16384
15
+
16
+
17
+ class TunneledTLS:
18
+ """The socket `http.client` needs (`sendall`, `makefile("rb")`, `settimeout`,
19
+ `shutdown`, `close`, `fileno`, `pending`) for a TLS session whose bytes
20
+ travel over `outer`, a connected `ssl.SSLSocket` to the proxy that has
21
+ already carried the `CONNECT`. Closing it closes `outer`.
22
+
23
+ The handshake runs on construction, with `context.wrap_bio()`, so a
24
+ context that verifies the chain (the system's included) does so here."""
25
+
26
+ def __init__(self, outer: "_ssl.SSLSocket", context: "_ssl.SSLContext", server_hostname: str):
27
+ self._outer = outer
28
+ self._incoming = _ssl.MemoryBIO()
29
+ self._outgoing = _ssl.MemoryBIO()
30
+ self._inner = context.wrap_bio(self._incoming, self._outgoing, server_hostname=server_hostname)
31
+ self._io_refs = 0
32
+ self._closed = False
33
+ self._run(self._inner.do_handshake)
34
+
35
+ def _run(self, call: "_ty.Callable[..., _ty.Any]", *args: _ty.Any) -> _ty.Any:
36
+ """`call(*args)` on the inner session, moving bytes between its buffers
37
+ and `outer` until it neither wants to read nor to write."""
38
+ while True:
39
+ wants_read = wants_write = False
40
+ result = None
41
+ try:
42
+ result = call(*args)
43
+ except _ssl.SSLWantReadError:
44
+ wants_read = True
45
+ except _ssl.SSLWantWriteError:
46
+ wants_write = True
47
+ produced = self._outgoing.read()
48
+ if produced:
49
+ self._outer.sendall(produced)
50
+ if not (wants_read or wants_write):
51
+ return result
52
+ if wants_read:
53
+ received = self._outer.recv(_BLOCK)
54
+ if received:
55
+ self._incoming.write(received)
56
+ else:
57
+ self._incoming.write_eof()
58
+
59
+ def recv_into(self, buffer, nbytes: "int | None" = None) -> int:
60
+ if nbytes is None:
61
+ nbytes = len(buffer)
62
+ try:
63
+ return self._run(self._inner.read, nbytes, buffer)
64
+ except (_ssl.SSLZeroReturnError, _ssl.SSLEOFError):
65
+ return 0 # the peer ended the session, with or without close_notify
66
+
67
+ def recv(self, size: int = _BLOCK, flags: int = 0) -> bytes:
68
+ buffer = bytearray(size)
69
+ return bytes(buffer[: self.recv_into(buffer)])
70
+
71
+ def sendall(self, data, flags: int = 0) -> None:
72
+ view = memoryview(data).cast("B")
73
+ while view:
74
+ sent = self._run(self._inner.write, view[:_BLOCK])
75
+ view = view[sent:]
76
+
77
+ def send(self, data, flags: int = 0) -> int:
78
+ return self._run(self._inner.write, memoryview(data).cast("B")[:_BLOCK])
79
+
80
+ def makefile(self, mode: str = "rb", buffering: "int | None" = None, **kwargs: _ty.Any):
81
+ """A buffered binary reader over `recv_into`, as `socket.makefile`
82
+ gives. Nothing else is read from a tunnelled session."""
83
+ if mode != "rb":
84
+ raise ValueError("a tunnelled TLS session is read as bytes: makefile('rb')")
85
+ raw = _socket.SocketIO(self, "r") # type: ignore[arg-type]
86
+ self._io_refs += 1
87
+ size = _io.DEFAULT_BUFFER_SIZE if buffering is None or buffering < 0 else buffering
88
+ return raw if size == 0 else _io.BufferedReader(raw, size)
89
+
90
+ def _decref_socketios(self) -> None:
91
+ if self._io_refs > 0:
92
+ self._io_refs -= 1
93
+ if self._closed:
94
+ self._outer.close()
95
+
96
+ def pending(self) -> int:
97
+ return self._inner.pending() + (self._outer.pending() or 0)
98
+
99
+ def fileno(self) -> int:
100
+ return self._outer.fileno()
101
+
102
+ def settimeout(self, value: "float | None") -> None:
103
+ self._outer.settimeout(value)
104
+
105
+ def gettimeout(self) -> "float | None":
106
+ return self._outer.gettimeout()
107
+
108
+ def shutdown(self, how: int) -> None:
109
+ self._outer.shutdown(how)
110
+
111
+ def close(self) -> None:
112
+ self._closed = True
113
+ if self._io_refs <= 0:
114
+ self._outer.close()