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/cookies.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Netscape cookie-file I/O and curl's cookie rules: ``CookieSpec``,
|
|
2
|
+
``load_netscape``, ``save_netscape``, ``cookie_applies``, ``set_cookie_specs``
|
|
3
|
+
and the host groups of ``host_aliases``. The client keeps its cookies as these
|
|
4
|
+
rows; a curl front end's ``-b`` / ``-c`` run on them too."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ipaddress
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, Iterable, List, Mapping, Optional, Protocol, Tuple
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
from ._fileio import read_text, write_private
|
|
17
|
+
from ._parse import ascii_int, http_date
|
|
18
|
+
|
|
19
|
+
__all__ = (
|
|
20
|
+
"CookieLike",
|
|
21
|
+
"CookieSpec",
|
|
22
|
+
"HTTP_ONLY_PREFIX",
|
|
23
|
+
"MAX_COOKIE_AGE",
|
|
24
|
+
"cookie_applies",
|
|
25
|
+
"domain_covers",
|
|
26
|
+
"host_aliases",
|
|
27
|
+
"load_netscape",
|
|
28
|
+
"save_netscape",
|
|
29
|
+
"set_cookie_specs",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CookieLike(Protocol):
|
|
34
|
+
"""What :func:`save_netscape` reads off a cookie: a ``CookieSpec``, an
|
|
35
|
+
``http.cookiejar.Cookie`` or any object with these attributes."""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def domain(self) -> str: ...
|
|
39
|
+
@property
|
|
40
|
+
def path(self) -> str: ...
|
|
41
|
+
@property
|
|
42
|
+
def secure(self) -> bool: ...
|
|
43
|
+
@property
|
|
44
|
+
def expires(self) -> Optional[int]: ...
|
|
45
|
+
@property
|
|
46
|
+
def name(self) -> str: ...
|
|
47
|
+
@property
|
|
48
|
+
def value(self) -> str: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class CookieSpec:
|
|
53
|
+
domain: str
|
|
54
|
+
path: str
|
|
55
|
+
secure: bool
|
|
56
|
+
expires: Optional[int]
|
|
57
|
+
name: str
|
|
58
|
+
value: str
|
|
59
|
+
#: Written as curl writes it: the domain field prefixed ``#HttpOnly_``.
|
|
60
|
+
http_only: bool = False
|
|
61
|
+
#: The Netscape include-subdomains flag (``TRUE``), for a domain written
|
|
62
|
+
#: without the leading dot that says the same.
|
|
63
|
+
subdomains: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
HTTP_ONLY_PREFIX = "#HttpOnly_"
|
|
67
|
+
|
|
68
|
+
#: What no cookie name or value may hold (RFC 6265bis 5.6): the controls but
|
|
69
|
+
#: the tab. A header that holds one is ignored, and a row that holds one is
|
|
70
|
+
#: neither read nor written, so a value cannot end a line of a jar file.
|
|
71
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f]")
|
|
72
|
+
#: What a jar file's row cannot carry in a field: every control, the tab (the
|
|
73
|
+
#: separator) included.
|
|
74
|
+
_UNSAFE_FIELD = re.compile(r"[\x00-\x1f\x7f]")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_netscape(path: Path) -> List[CookieSpec]:
|
|
78
|
+
"""The rows of the Netscape cookie file ``path`` (``[]`` when it is
|
|
79
|
+
missing). Lines end at ``\\n`` only, one trailing ``\\r`` taken off, and a
|
|
80
|
+
row whose name or value holds a control character is skipped: other
|
|
81
|
+
characters ``str.splitlines`` honours (``\\x0b``, ``\\x0c``, ``\\x85``) are
|
|
82
|
+
part of a value, not the end of a row."""
|
|
83
|
+
try:
|
|
84
|
+
text = read_text(path)
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
return []
|
|
87
|
+
cookies: List[CookieSpec] = []
|
|
88
|
+
for line in text.split("\n"):
|
|
89
|
+
if line.endswith("\r"):
|
|
90
|
+
line = line[:-1]
|
|
91
|
+
# Only the leading blanks go: a trailing tab separates an empty value.
|
|
92
|
+
line = line.lstrip(" ")
|
|
93
|
+
# curl writes an HttpOnly cookie's domain as `#HttpOnly_<domain>`; any
|
|
94
|
+
# other `#` line is a comment.
|
|
95
|
+
if not line.strip() or (line.startswith("#") and not line.startswith(HTTP_ONLY_PREFIX)):
|
|
96
|
+
continue
|
|
97
|
+
parts = line.split("\t")
|
|
98
|
+
if len(parts) == 6: # an editor trimmed the empty value's tab
|
|
99
|
+
parts.append("")
|
|
100
|
+
if len(parts) < 7:
|
|
101
|
+
continue
|
|
102
|
+
domain, flag, cpath, secure, expires, name, value = parts[:7]
|
|
103
|
+
if _CONTROL.search(name) or _CONTROL.search(value):
|
|
104
|
+
continue
|
|
105
|
+
http_only = domain.startswith(HTTP_ONLY_PREFIX)
|
|
106
|
+
if http_only:
|
|
107
|
+
domain = domain[len(HTTP_ONLY_PREFIX) :]
|
|
108
|
+
cookies.append(
|
|
109
|
+
CookieSpec(
|
|
110
|
+
domain=domain,
|
|
111
|
+
path=cpath or "/",
|
|
112
|
+
secure=secure.strip().upper() == "TRUE",
|
|
113
|
+
# `0` is how the format writes a session cookie: no expiry.
|
|
114
|
+
expires=ascii_int(expires) or None,
|
|
115
|
+
name=name,
|
|
116
|
+
value=value,
|
|
117
|
+
http_only=http_only,
|
|
118
|
+
subdomains=flag.strip().upper() == "TRUE",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return cookies
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
#: Hosts curl treats as a secure context: ``Secure`` cookies go to them over
|
|
125
|
+
#: plain http too.
|
|
126
|
+
_SECURE_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _path_matches(cookie_path: str, path: str) -> bool:
|
|
130
|
+
"""RFC 6265 path-match: ``/admin`` covers ``/admin`` and ``/admin/x``,
|
|
131
|
+
not ``/adminx``."""
|
|
132
|
+
if path == cookie_path or cookie_path == "/":
|
|
133
|
+
return True
|
|
134
|
+
return path.startswith(cookie_path) and (cookie_path.endswith("/") or path[len(cookie_path)] == "/")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cookie_applies(cookie: CookieSpec, url: str, now: Optional[float] = None) -> bool:
|
|
138
|
+
"""Does ``cookie`` go with a request to ``url``, as curl decides: the
|
|
139
|
+
domain (the host itself, or a subdomain for a leading dot or the
|
|
140
|
+
include-subdomains flag -- never for an IP address, which only matches
|
|
141
|
+
itself), the path (RFC 6265 path-match), ``Secure`` only over https or to
|
|
142
|
+
localhost / a loopback address, and not expired (``0`` / ``None`` is a
|
|
143
|
+
session cookie)."""
|
|
144
|
+
parts = urlparse(url)
|
|
145
|
+
host = (parts.hostname or "").lower()
|
|
146
|
+
domain = cookie.domain
|
|
147
|
+
try:
|
|
148
|
+
ipaddress.ip_address(host)
|
|
149
|
+
if domain.lstrip(".").lower() != host:
|
|
150
|
+
return False
|
|
151
|
+
except ValueError:
|
|
152
|
+
if cookie.subdomains and not domain.startswith("."):
|
|
153
|
+
domain = "." + domain
|
|
154
|
+
if not domain_covers(domain, host):
|
|
155
|
+
return False
|
|
156
|
+
if not _path_matches(cookie.path or "/", parts.path or "/"):
|
|
157
|
+
return False
|
|
158
|
+
if cookie.secure and parts.scheme != "https" and host not in _SECURE_HOSTS:
|
|
159
|
+
return False
|
|
160
|
+
if cookie.expires and cookie.expires <= (time.time() if now is None else now):
|
|
161
|
+
return False
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
#: Longest lifetime a cookie keeps (RFC 6265bis section 5.5): a later expiry is
|
|
166
|
+
#: cut to it.
|
|
167
|
+
MAX_COOKIE_AGE = 400 * 24 * 3600
|
|
168
|
+
|
|
169
|
+
#: Second-level labels under a two-letter country code that are registries,
|
|
170
|
+
#: not owners (``co.uk``): the list ``http.cookiejar`` uses.
|
|
171
|
+
_REGISTRY_LABELS = frozenset(
|
|
172
|
+
"co ac com edu org net gov mil int aero biz cat coop info jobs mobi museum name pro travel eu".split()
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _is_ip(host: str) -> bool:
|
|
177
|
+
try:
|
|
178
|
+
ipaddress.ip_address(host)
|
|
179
|
+
except ValueError:
|
|
180
|
+
return False
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _is_public_suffix(domain: str) -> bool:
|
|
185
|
+
"""A domain no site owns, judged without the Public Suffix List: a top
|
|
186
|
+
level domain (no dot, which makes ``localhost`` one) or a registry under a
|
|
187
|
+
country code (``co.uk``). Others (``github.io``) are not recognised."""
|
|
188
|
+
labels = domain.split(".")
|
|
189
|
+
return len(labels) == 1 or (len(labels) == 2 and labels[0] in _REGISTRY_LABELS and len(labels[1]) == 2)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _cookie_domain(host: str, attribute: Optional[str]) -> Optional[Tuple[str, bool]]:
|
|
193
|
+
"""RFC 6265 section 5.3 steps 4-6: ``(domain, subdomains)`` for a cookie
|
|
194
|
+
set by ``host`` with the Domain ``attribute``, or ``None`` to drop it. No
|
|
195
|
+
attribute, an IP host, and a public-suffix Domain equal to the host
|
|
196
|
+
(``localhost``) give a host-only cookie."""
|
|
197
|
+
host = host.lower()
|
|
198
|
+
domain = (attribute or "").strip()
|
|
199
|
+
domain = (domain[1:] if domain.startswith(".") else domain).lower()
|
|
200
|
+
if not domain:
|
|
201
|
+
return host, False
|
|
202
|
+
if _is_ip(host):
|
|
203
|
+
return (host, False) if domain == host else None
|
|
204
|
+
if "" in domain.split(".") or _is_ip(domain):
|
|
205
|
+
return None
|
|
206
|
+
if host != domain and not host.endswith("." + domain):
|
|
207
|
+
return None
|
|
208
|
+
if _is_public_suffix(domain):
|
|
209
|
+
return (host, False) if domain == host else None
|
|
210
|
+
return domain, True
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
_HOST_NAME = re.compile(r"[a-z0-9_-]+(\.[a-z0-9_-]+)*")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _normal_host(host: str) -> str:
|
|
217
|
+
"""``host`` as the alias table keys it: lower case, without IPv6 brackets
|
|
218
|
+
or a trailing dot."""
|
|
219
|
+
host = host.strip().lower()
|
|
220
|
+
if host.startswith("[") and host.endswith("]"):
|
|
221
|
+
host = host[1:-1]
|
|
222
|
+
return host[:-1] if host.endswith(".") else host
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def host_aliases(groups: Iterable[Iterable[str]]) -> Dict[str, str]:
|
|
226
|
+
"""Each host of ``groups`` mapped to its group's first host: hosts a user
|
|
227
|
+
declares to be one for cookies. A group of one host is no alias. A host
|
|
228
|
+
that is not a name or an IP address (a port, a path, a blank), or that
|
|
229
|
+
appears in two groups, raises ``ValueError``."""
|
|
230
|
+
aliases: Dict[str, str] = {}
|
|
231
|
+
for group in groups:
|
|
232
|
+
if isinstance(group, str):
|
|
233
|
+
raise TypeError("a cookie host group is a list of hosts, not the string %r" % group)
|
|
234
|
+
hosts = []
|
|
235
|
+
for raw in group:
|
|
236
|
+
host = _normal_host(raw)
|
|
237
|
+
if not (_is_ip(host) or _HOST_NAME.fullmatch(host)):
|
|
238
|
+
raise ValueError("%r is not a host name or an IP address" % raw)
|
|
239
|
+
if host in aliases:
|
|
240
|
+
raise ValueError("%r is in two cookie host groups" % raw)
|
|
241
|
+
if host not in hosts:
|
|
242
|
+
hosts.append(host)
|
|
243
|
+
if len(hosts) > 1:
|
|
244
|
+
aliases.update((host, hosts[0]) for host in hosts)
|
|
245
|
+
return aliases
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _placed_for(
|
|
249
|
+
host: str, attribute: Optional[str], aliases: Optional[Mapping[str, str]]
|
|
250
|
+
) -> Optional[Tuple[str, bool]]:
|
|
251
|
+
""":func:`_cookie_domain` with ``host``'s alias group as one host: a
|
|
252
|
+
cookie with no Domain, or a Domain naming a host of the group, belongs to
|
|
253
|
+
the group's first host. A Domain outside the group is judged on ``host``."""
|
|
254
|
+
target = aliases.get(_normal_host(host)) if aliases else None
|
|
255
|
+
if target is None:
|
|
256
|
+
return _cookie_domain(host, attribute)
|
|
257
|
+
named = _normal_host((attribute or "").strip().lstrip("."))
|
|
258
|
+
if not named:
|
|
259
|
+
return target, False
|
|
260
|
+
if aliases.get(named) == target: # type: ignore[union-attr]
|
|
261
|
+
return _cookie_domain(target, target)
|
|
262
|
+
return _cookie_domain(host, attribute)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _default_path(request_path: str) -> str:
|
|
266
|
+
"""RFC 6265 section 5.1.4: the directory of the request path."""
|
|
267
|
+
path = request_path.split("?", 1)[0].split("#", 1)[0]
|
|
268
|
+
if not path.startswith("/") or path.count("/") == 1:
|
|
269
|
+
return "/"
|
|
270
|
+
return path[: path.rindex("/")]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_set_cookie(header: str, now: float) -> "Optional[Tuple[str, str, Dict[str, str]]]":
|
|
274
|
+
"""RFC 6265 section 5.2: ``(name, value, attributes)`` of one header, the
|
|
275
|
+
attribute names lower-case; an expiry is resolved into ``expires`` (a unix
|
|
276
|
+
time as text). ``None`` when the header holds no cookie, or holds a
|
|
277
|
+
control character (the tab apart), which RFC 6265bis 5.6 and curl ignore
|
|
278
|
+
the whole cookie for."""
|
|
279
|
+
if _CONTROL.search(header):
|
|
280
|
+
return None
|
|
281
|
+
pair, _, rest = header.partition(";")
|
|
282
|
+
name, eq, value = pair.partition("=")
|
|
283
|
+
name, value = name.strip(), value.strip()
|
|
284
|
+
if not eq or not name:
|
|
285
|
+
return None
|
|
286
|
+
attrs: Dict[str, str] = {}
|
|
287
|
+
expires: Optional[float] = None
|
|
288
|
+
max_age: Optional[int] = None
|
|
289
|
+
for item in rest.split(";"):
|
|
290
|
+
key, _, val = item.partition("=")
|
|
291
|
+
key, val = key.strip().lower(), val.strip()
|
|
292
|
+
if key == "expires":
|
|
293
|
+
when = http_date(val)
|
|
294
|
+
if when is not None:
|
|
295
|
+
expires = when
|
|
296
|
+
elif key == "max-age":
|
|
297
|
+
seconds = ascii_int(val[1:] if val.startswith("-") else val)
|
|
298
|
+
if seconds is not None:
|
|
299
|
+
max_age = -seconds if val.startswith("-") else seconds
|
|
300
|
+
elif key in ("domain", "path"):
|
|
301
|
+
if val:
|
|
302
|
+
attrs[key] = val
|
|
303
|
+
elif key in ("secure", "httponly"):
|
|
304
|
+
attrs[key] = "1"
|
|
305
|
+
if max_age is not None: # Max-Age beats Expires, whatever the order
|
|
306
|
+
expires = now + max_age if max_age > 0 else now
|
|
307
|
+
if expires is not None:
|
|
308
|
+
attrs["expires"] = str(int(min(expires, now + MAX_COOKIE_AGE)))
|
|
309
|
+
return name, value, attrs
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def set_cookie_specs(
|
|
313
|
+
values: Iterable[str],
|
|
314
|
+
default_domain: str,
|
|
315
|
+
now: Optional[float] = None,
|
|
316
|
+
*,
|
|
317
|
+
request_path: str = "/",
|
|
318
|
+
aliases: Optional[Mapping[str, str]] = None,
|
|
319
|
+
) -> List[Tuple[CookieSpec, bool]]:
|
|
320
|
+
"""``(cookie, gone)`` for every cookie the ``Set-Cookie`` header
|
|
321
|
+
``values`` carry, read as RFC 6265 section 5.2 does.
|
|
322
|
+
|
|
323
|
+
``default_domain`` is the host asked. A ``Domain`` attribute is kept only
|
|
324
|
+
when that host domain-matches it and it is not a public suffix (judged
|
|
325
|
+
without the Public Suffix List: a top level domain or a ``co.uk``-style
|
|
326
|
+
registry); it then covers subdomains (``subdomains=True``). Without one
|
|
327
|
+
the cookie is host-only. A cookie with no ``Path`` gets the directory of
|
|
328
|
+
``request_path``. ``gone`` marks a cookie the server expires: an empty
|
|
329
|
+
value, ``Max-Age`` of zero or less, a past date. A header that is not a
|
|
330
|
+
cookie, or names a foreign domain, is skipped. An expiry beyond 400 days
|
|
331
|
+
is cut to 400 days. ``aliases`` (:func:`host_aliases`) makes a group of
|
|
332
|
+
hosts one: a cookie a member sets with no Domain, or with a Domain naming
|
|
333
|
+
a member, belongs to the group's first host."""
|
|
334
|
+
now = time.time() if now is None else now
|
|
335
|
+
out: List[Tuple[CookieSpec, bool]] = []
|
|
336
|
+
for header in values:
|
|
337
|
+
parsed = _parse_set_cookie(header, now)
|
|
338
|
+
if parsed is None:
|
|
339
|
+
continue
|
|
340
|
+
name, value, attrs = parsed
|
|
341
|
+
placed = _placed_for(default_domain, attrs.get("domain"), aliases)
|
|
342
|
+
if placed is None:
|
|
343
|
+
continue
|
|
344
|
+
domain, subdomains = placed
|
|
345
|
+
path = attrs.get("path", "")
|
|
346
|
+
expires = int(attrs["expires"]) if "expires" in attrs else None
|
|
347
|
+
spec = CookieSpec(
|
|
348
|
+
domain=domain,
|
|
349
|
+
path=path if path.startswith("/") else _default_path(request_path),
|
|
350
|
+
secure="secure" in attrs,
|
|
351
|
+
expires=expires,
|
|
352
|
+
name=name,
|
|
353
|
+
value=value,
|
|
354
|
+
http_only="httponly" in attrs,
|
|
355
|
+
subdomains=subdomains,
|
|
356
|
+
)
|
|
357
|
+
out.append((spec, not value or (expires is not None and expires <= int(now))))
|
|
358
|
+
return out
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def domain_covers(domain: str, host: str) -> bool:
|
|
362
|
+
"""Does a cookie ``domain`` apply to ``host``: the host itself, or a
|
|
363
|
+
subdomain when the domain has a leading dot (the Netscape flag)."""
|
|
364
|
+
d = (domain or "").lower()
|
|
365
|
+
h = (host or "").lower()
|
|
366
|
+
if not d or not h:
|
|
367
|
+
return False
|
|
368
|
+
bare = d.lstrip(".")
|
|
369
|
+
return h == bare or (d.startswith(".") and h.endswith("." + bare))
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def save_netscape(session_cookies: "Iterable[CookieLike]", path: Path) -> None:
|
|
373
|
+
"""Writes ``session_cookies`` to ``path`` as a Netscape cookie file, owner
|
|
374
|
+
only and replaced whole. A cookie with no name, or with a control character
|
|
375
|
+
or a tab in a field, is not written: it would split its row."""
|
|
376
|
+
lines = ["# Netscape HTTP Cookie File\n"]
|
|
377
|
+
for cookie in session_cookies:
|
|
378
|
+
domain = getattr(cookie, "domain", "") or ""
|
|
379
|
+
flag = "TRUE" if domain.startswith(".") or getattr(cookie, "subdomains", False) else "FALSE"
|
|
380
|
+
if getattr(cookie, "http_only", False):
|
|
381
|
+
domain = HTTP_ONLY_PREFIX + domain
|
|
382
|
+
cpath = getattr(cookie, "path", "/") or "/"
|
|
383
|
+
secure = "TRUE" if bool(getattr(cookie, "secure", False)) else "FALSE"
|
|
384
|
+
expires_val = getattr(cookie, "expires", None)
|
|
385
|
+
expires = str(int(expires_val)) if expires_val else "0"
|
|
386
|
+
name = getattr(cookie, "name", "")
|
|
387
|
+
value = getattr(cookie, "value", "")
|
|
388
|
+
if not name or any(_UNSAFE_FIELD.search(str(field)) for field in (domain, cpath, name, value)):
|
|
389
|
+
continue
|
|
390
|
+
lines.append("%s\t%s\t%s\t%s\t%s\t%s\t%s\n" % (domain, flag, cpath, secure, expires, name, value))
|
|
391
|
+
write_private(path, "".join(lines).encode("utf-8"))
|
syshttp/env.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Where syshttp's settings come from: a list of prefixes, one environment
|
|
2
|
+
mapping.
|
|
3
|
+
|
|
4
|
+
Every setting is a variable with a full name -- syshttp's own carry
|
|
5
|
+
``SYSHTTP_`` (``SYSHTTP_PROXY``, ``SYSHTTP_HOME``, ``SYSHTTP_HOOKS_<KEY>``),
|
|
6
|
+
conventional ones keep theirs (``HTTPS_PROXY``, ``NO_PROXY``, ``CA_BUNDLE``)
|
|
7
|
+
-- read as ``<PREFIX><NAME>`` for each prefix in turn, the first one set
|
|
8
|
+
winning. The prefixes come from ``SYSHTTP_PREFIX``, a ``:``-separated list
|
|
9
|
+
taken literally (``AGENTS_`` reads ``AGENTS_HTTPS_PROXY``); blank or unset is
|
|
10
|
+
one empty prefix, the names as written. A tool embedding syshttp therefore
|
|
11
|
+
opts out of the defaults with ``SYSHTTP_PREFIX=MYTOOL_`` or layers its own over
|
|
12
|
+
them with ``MYTOOL_:`` (the empty entry last).
|
|
13
|
+
|
|
14
|
+
An :class:`HTTPEnv` holds one such view. Two of them with different prefixes or
|
|
15
|
+
mappings never see each other's settings, which is how one process serves
|
|
16
|
+
several configurations; every function that reads a setting takes one as
|
|
17
|
+
``env=`` and builds a default ``HTTPEnv()`` when given none. Nothing is read at
|
|
18
|
+
import: a variable set later takes effect on the next call.
|
|
19
|
+
|
|
20
|
+
A CGI program gets every request header as an ``HTTP_*`` variable, so a
|
|
21
|
+
client could choose ``HTTP_PROXY`` and its kin: under CGI, set a prefix list
|
|
22
|
+
without the empty entry.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
from typing import Iterable, Iterator, Mapping, Optional, Tuple, Union
|
|
30
|
+
|
|
31
|
+
from .exceptions import ConfigurationError
|
|
32
|
+
|
|
33
|
+
__all__ = ["PREFIX_VAR", "HTTPEnv"]
|
|
34
|
+
|
|
35
|
+
#: The variable naming the prefixes, ``:``-separated. Read as written: it has
|
|
36
|
+
#: no prefix of its own.
|
|
37
|
+
PREFIX_VAR = "SYSHTTP_PREFIX"
|
|
38
|
+
|
|
39
|
+
_PREFIX_RE = re.compile(r"[A-Za-z0-9_]*\Z")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_prefix(prefix: str, source: str) -> str:
|
|
43
|
+
# The argument is a caller's mistake (ValueError); the variable is a setting.
|
|
44
|
+
prefix = prefix.strip()
|
|
45
|
+
if not _PREFIX_RE.match(prefix):
|
|
46
|
+
error = ConfigurationError if source == PREFIX_VAR else ValueError
|
|
47
|
+
raise error("%s holds letters, digits and underscores only, not %r" % (source, prefix))
|
|
48
|
+
# Upper case, as variable names are: Windows upper-cases os.environ's keys,
|
|
49
|
+
# so a lower-case prefix would match there and nowhere else.
|
|
50
|
+
return prefix.upper()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _prefix_list(prefixes: Iterable[str], source: str) -> Tuple[str, ...]:
|
|
54
|
+
found = []
|
|
55
|
+
for prefix in prefixes:
|
|
56
|
+
prefix = _check_prefix(prefix, source)
|
|
57
|
+
if prefix not in found:
|
|
58
|
+
found.append(prefix)
|
|
59
|
+
return tuple(found)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class HTTPEnv:
|
|
63
|
+
"""A view of the environment through a list of prefixes.
|
|
64
|
+
|
|
65
|
+
``prefixes`` -- ``None`` (read ``SYSHTTP_PREFIX`` at each lookup; blank or
|
|
66
|
+
unset is ``("",)``), a ``:``-separated string, or an iterable of prefixes;
|
|
67
|
+
each is used literally and upper-cased, ``""`` meaning the names as
|
|
68
|
+
written, and an empty iterable reading nothing. ``environ`` -- the mapping
|
|
69
|
+
to read, default ``os.environ`` as it is at each lookup. A malformed
|
|
70
|
+
prefix is a ``ValueError``: from the constructor for an argument, from
|
|
71
|
+
the first lookup for ``SYSHTTP_PREFIX`` (a ``ConfigurationError``).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
__slots__ = ("_prefixes", "_environ")
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self, prefixes: Union[str, Iterable[str], None] = None, environ: Optional[Mapping[str, str]] = None
|
|
78
|
+
) -> None:
|
|
79
|
+
if isinstance(prefixes, str):
|
|
80
|
+
prefixes = prefixes.split(":")
|
|
81
|
+
self._prefixes = None if prefixes is None else _prefix_list(prefixes, "prefixes")
|
|
82
|
+
self._environ = environ
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def environ(self) -> Mapping[str, str]:
|
|
86
|
+
"""The mapping settings are read from."""
|
|
87
|
+
return os.environ if self._environ is None else self._environ
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def prefixes(self) -> Tuple[str, ...]:
|
|
91
|
+
"""The prefixes in force, in lookup order: the argument, else
|
|
92
|
+
``SYSHTTP_PREFIX``, else ``("",)``."""
|
|
93
|
+
if self._prefixes is not None:
|
|
94
|
+
return self._prefixes
|
|
95
|
+
raw = (self.environ.get(PREFIX_VAR) or "").strip()
|
|
96
|
+
return _prefix_list(raw.split(":"), PREFIX_VAR) if raw else ("",)
|
|
97
|
+
|
|
98
|
+
def name(self, name: str) -> str:
|
|
99
|
+
"""The variable for ``name`` under the first prefix: what a message
|
|
100
|
+
names when nothing is set, and what a child process is given."""
|
|
101
|
+
prefixes = self.prefixes
|
|
102
|
+
return (prefixes[0] if prefixes else "") + name
|
|
103
|
+
|
|
104
|
+
def find(self, *names: str) -> Optional[Tuple[str, str]]:
|
|
105
|
+
"""``(variable, value)`` for the first of ``names`` set, not blank,
|
|
106
|
+
under the first prefix that has one (every name is tried under a
|
|
107
|
+
prefix before the next prefix); the value stripped. ``None`` when
|
|
108
|
+
none is."""
|
|
109
|
+
env = self.environ
|
|
110
|
+
for prefix in self.prefixes:
|
|
111
|
+
for name in names:
|
|
112
|
+
variable = prefix + name
|
|
113
|
+
value = (env.get(variable) or "").strip()
|
|
114
|
+
if value:
|
|
115
|
+
return variable, value
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def get(self, *names: str) -> Optional[str]:
|
|
119
|
+
"""The value :meth:`find` finds, or ``None``."""
|
|
120
|
+
found = self.find(*names)
|
|
121
|
+
return found[1] if found else None
|
|
122
|
+
|
|
123
|
+
def family(self, stem: str) -> Iterator[Tuple[str, str, str]]:
|
|
124
|
+
"""``(rest, value, variable)`` for every variable named
|
|
125
|
+
``<PREFIX><stem><rest>`` with a non-empty ``rest`` and a value that is
|
|
126
|
+
not blank (stripped), the first prefix winning for a ``rest`` (compared
|
|
127
|
+
upper-cased); in prefix order, then name order."""
|
|
128
|
+
env = self.environ
|
|
129
|
+
seen = set()
|
|
130
|
+
for prefix in self.prefixes:
|
|
131
|
+
head = prefix + stem
|
|
132
|
+
for variable in sorted(env):
|
|
133
|
+
if not variable.startswith(head) or len(variable) == len(head):
|
|
134
|
+
continue
|
|
135
|
+
rest = variable[len(head) :]
|
|
136
|
+
value = (env[variable] or "").strip()
|
|
137
|
+
if value and rest.upper() not in seen:
|
|
138
|
+
seen.add(rest.upper())
|
|
139
|
+
yield rest, value, variable
|
|
140
|
+
|
|
141
|
+
def __repr__(self) -> str:
|
|
142
|
+
prefixes = "from %s" % PREFIX_VAR if self._prefixes is None else repr(":".join(self._prefixes))
|
|
143
|
+
source = "os.environ" if self._environ is None else "a mapping"
|
|
144
|
+
return "HTTPEnv(prefixes %s, %s)" % (prefixes, source)
|
syshttp/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""What syshttp raises when something goes wrong at run time.
|
|
2
|
+
|
|
3
|
+
A caller's mistake -- a bad argument, two options that contradict -- is a plain
|
|
4
|
+
``ValueError`` or ``TypeError``, not one of these. Every message names the
|
|
5
|
+
setting or the key involved and never a secret's value."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__ = ("ConfigurationError", "HookError", "ResponseError", "SysHTTPError")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SysHTTPError(Exception):
|
|
13
|
+
"""The base of every error syshttp raises for what went wrong at run time
|
|
14
|
+
(proxylib's own errors pass through as they are)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigurationError(SysHTTPError, ValueError):
|
|
18
|
+
"""A setting, or a stored credential, that cannot be used: a malformed
|
|
19
|
+
environment variable, a hook that is not loadable, a token with a line
|
|
20
|
+
break in it. The message names the variable or the key."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HookError(SysHTTPError, RuntimeError):
|
|
24
|
+
"""A ``_PY`` URL hook failed. The hook's own exception is the ``__cause__``."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResponseError(SysHTTPError, ValueError):
|
|
28
|
+
"""A response whose body is not what the helper was asked for (JSON that is
|
|
29
|
+
not JSON)."""
|