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/hooks.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""URL hooks: a wrapper a curl front end runs, or a callable ``syshttp``
|
|
2
|
+
calls, for requests whose URL matches a pattern -- declared in the
|
|
3
|
+
environment, each variable read under the prefixes of an
|
|
4
|
+
:class:`~syshttp.env.HTTPEnv`. ``SYSHTTP_HOOKS_<KEY>`` (plural) declares a
|
|
5
|
+
hook; ``SYSHTTP_HOOK_<NAME>`` (singular) is a variable one hook RUN exports to
|
|
6
|
+
its wrapper.
|
|
7
|
+
|
|
8
|
+
SYSHTTP_HOOKS_<KEY>=<regex> matched (re.search) against the URL
|
|
9
|
+
the caller asked for -- the origin URL,
|
|
10
|
+
never the proxy's or a gateway's rewrite
|
|
11
|
+
SYSHTTP_HOOKS_<KEY>_CURL=<command> a curl front end runs THIS instead of
|
|
12
|
+
itself, with its own argv appended
|
|
13
|
+
SYSHTTP_HOOKS_<KEY>_PY=<module:callable> syshttp calls it before the request:
|
|
14
|
+
callable(client, method, url, kwargs)
|
|
15
|
+
|
|
16
|
+
A hook does what it wants with the client (log in, set a header in
|
|
17
|
+
``kwargs["headers"]``, refresh a token in the jar) and returns ``None`` to
|
|
18
|
+
let the request proceed; anything else it returns is used AS the response
|
|
19
|
+
(an ``HTTPResponse``, cached or synthetic). ``kwargs`` is the keyword
|
|
20
|
+
arguments the ``HTTPClient.request()`` call gave (``data``, ``headers``,
|
|
21
|
+
``stream``, ``allow_redirects``, ``timeout``, ``deadline``, ``auth``),
|
|
22
|
+
mutable in place. ``<module:callable>`` is importable from ``sys.path`` or
|
|
23
|
+
a ``<path>.py:callable`` file.
|
|
24
|
+
|
|
25
|
+
The curl wrapper is a shell-quoted command line -- a program and its own
|
|
26
|
+
arguments -- that the front end's whole argv is appended to::
|
|
27
|
+
|
|
28
|
+
SYSHTTP_HOOKS_X_CURL='cmd fetch --' -> cmd fetch -- "$@"
|
|
29
|
+
|
|
30
|
+
Quote (single or double) what has spaces; a backslash is literal on every
|
|
31
|
+
platform, so a Windows path needs no doubling. A first word that is a
|
|
32
|
+
``.py`` file runs under the front end's interpreter. The wrapper receives
|
|
33
|
+
(:func:`wrapper_environment`) ``SYSHTTP_HOOK_URL`` (the requested URL, so it
|
|
34
|
+
need not parse argv), ``SYSHTTP_HOOK_KEY`` (which hook matched),
|
|
35
|
+
``SYSHTTP_HOOK_CURL`` naming the front end so it can call curl back after its
|
|
36
|
+
own work, and ``SYSHTTP_HOOK_SKIP`` carrying its KEY, so that call does not run the same
|
|
37
|
+
wrapper again. A hook with only a ``_PY`` target is ignored by a curl front
|
|
38
|
+
end, one with only ``_CURL`` by syshttp.
|
|
39
|
+
|
|
40
|
+
Several hooks may match one URL: syshttp calls each in KEY order (sorted),
|
|
41
|
+
the first returning a response ends the chain; a curl front end runs the
|
|
42
|
+
first ``_CURL`` in that order. A pattern that does not compile is a
|
|
43
|
+
configuration error (``ValueError`` naming the variable) the first time it
|
|
44
|
+
would be consulted.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import hashlib
|
|
50
|
+
import importlib
|
|
51
|
+
import importlib.util
|
|
52
|
+
import re
|
|
53
|
+
import shlex
|
|
54
|
+
import sys
|
|
55
|
+
import threading
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
|
|
58
|
+
from urllib.parse import urlsplit
|
|
59
|
+
|
|
60
|
+
from .env import HTTPEnv
|
|
61
|
+
from .exceptions import ConfigurationError, HookError
|
|
62
|
+
|
|
63
|
+
if TYPE_CHECKING: # the client imports this module: types only
|
|
64
|
+
from .client import HTTPClient, HTTPResponse
|
|
65
|
+
|
|
66
|
+
__all__ = (
|
|
67
|
+
"CURL_SUFFIX",
|
|
68
|
+
"HOOKS",
|
|
69
|
+
"HOOK_CURL",
|
|
70
|
+
"HOOK_KEY",
|
|
71
|
+
"HOOK_SKIP",
|
|
72
|
+
"HOOK_URL",
|
|
73
|
+
"Hook",
|
|
74
|
+
"PY_SUFFIX",
|
|
75
|
+
"PyHook",
|
|
76
|
+
"call_py_hooks",
|
|
77
|
+
"curl_command",
|
|
78
|
+
"hooks_from_env",
|
|
79
|
+
"load_callable",
|
|
80
|
+
"load_object",
|
|
81
|
+
"matching",
|
|
82
|
+
"skipped",
|
|
83
|
+
"split_command",
|
|
84
|
+
"wrapper_environment",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
#: What a ``_PY`` hook is: ``(client, method, url, kwargs) -> None | response``.
|
|
88
|
+
PyHook = Callable[["HTTPClient", str, str, Dict[str, Any]], "Optional[HTTPResponse]"]
|
|
89
|
+
|
|
90
|
+
#: The settings, each read under every prefix: ``HOOKS`` + KEY declares a
|
|
91
|
+
#: hook, the ``HOOK_*`` ones are what a hook run exports to its wrapper.
|
|
92
|
+
HOOKS = "SYSHTTP_HOOKS_"
|
|
93
|
+
CURL_SUFFIX = "_CURL"
|
|
94
|
+
PY_SUFFIX = "_PY"
|
|
95
|
+
HOOK_SKIP = "SYSHTTP_HOOK_SKIP"
|
|
96
|
+
HOOK_URL = "SYSHTTP_HOOK_URL"
|
|
97
|
+
HOOK_KEY = "SYSHTTP_HOOK_KEY"
|
|
98
|
+
HOOK_CURL = "SYSHTTP_HOOK_CURL"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _env(env: Optional[HTTPEnv]) -> HTTPEnv:
|
|
102
|
+
return HTTPEnv() if env is None else env
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Hook(object):
|
|
106
|
+
"""One declared hook. ``variable`` is the name of the variable declaring
|
|
107
|
+
it (``SYSHTTP_HOOKS_X``), which every message about the hook names."""
|
|
108
|
+
|
|
109
|
+
__slots__ = ("key", "pattern", "curl", "py", "variable")
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
key: str,
|
|
114
|
+
pattern: "re.Pattern[str]",
|
|
115
|
+
curl: Optional[str],
|
|
116
|
+
py: Optional[str],
|
|
117
|
+
variable: Optional[str] = None,
|
|
118
|
+
):
|
|
119
|
+
self.key = key
|
|
120
|
+
self.pattern = pattern
|
|
121
|
+
self.curl = curl
|
|
122
|
+
self.py = py
|
|
123
|
+
self.variable = variable or HOOKS + key
|
|
124
|
+
|
|
125
|
+
def matches(self, url: str) -> bool:
|
|
126
|
+
return self.pattern.search(url) is not None
|
|
127
|
+
|
|
128
|
+
def __repr__(self) -> str:
|
|
129
|
+
return "Hook(%s, %r%s%s)" % (
|
|
130
|
+
self.key,
|
|
131
|
+
self.pattern.pattern,
|
|
132
|
+
", curl" if self.curl else "",
|
|
133
|
+
", py" if self.py else "",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def hooks_from_env(*, env: Optional[HTTPEnv] = None) -> List[Hook]:
|
|
138
|
+
"""Every configured hook, in KEY order. KEYs are upper-cased (variable
|
|
139
|
+
names are compared as Windows compares them), so ``SYSHTTP_HOOKS_mykey``
|
|
140
|
+
and ``SYSHTTP_HOOKS_MYKEY`` are one hook; the first prefix that declares a
|
|
141
|
+
variable of the hook wins for that variable. A ``_CURL``/``_PY`` variable whose KEY has
|
|
142
|
+
no pattern is ignored (nothing to match), and so is a blank target."""
|
|
143
|
+
env = _env(env)
|
|
144
|
+
found = {rest.upper(): (value, variable) for rest, value, variable in env.family(HOOKS)}
|
|
145
|
+
hooks = []
|
|
146
|
+
for key in sorted(found):
|
|
147
|
+
if key.endswith((CURL_SUFFIX, PY_SUFFIX)):
|
|
148
|
+
continue
|
|
149
|
+
value, variable = found[key]
|
|
150
|
+
try:
|
|
151
|
+
pattern = re.compile(value)
|
|
152
|
+
except re.error as exc:
|
|
153
|
+
raise ConfigurationError("%s is not a valid regular expression: %s" % (variable, exc))
|
|
154
|
+
curl = found.get(key + CURL_SUFFIX, (None, None))[0]
|
|
155
|
+
py = found.get(key + PY_SUFFIX, (None, None))[0]
|
|
156
|
+
if curl or py:
|
|
157
|
+
hooks.append(Hook(key, pattern, curl, py, variable))
|
|
158
|
+
return hooks
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def skipped(*, env: Optional[HTTPEnv] = None) -> "set[str]":
|
|
162
|
+
"""The KEYs ``SYSHTTP_HOOK_SKIP`` (a comma list) says not to run, upper-cased."""
|
|
163
|
+
return {k.strip().upper() for k in (_env(env).get(HOOK_SKIP) or "").split(",") if k.strip()}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def matching(url: str, *, kind: Optional[str] = None, env: Optional[HTTPEnv] = None) -> List[Hook]:
|
|
167
|
+
"""The hooks for ``url`` (the caller's URL), KEY order, minus the skipped
|
|
168
|
+
ones; ``kind`` (``"curl"`` / ``"py"``) keeps only hooks with that target."""
|
|
169
|
+
skip = skipped(env=env)
|
|
170
|
+
out = []
|
|
171
|
+
for hook in hooks_from_env(env=env):
|
|
172
|
+
if hook.key.upper() in skip or not hook.matches(url):
|
|
173
|
+
continue
|
|
174
|
+
if kind and not getattr(hook, kind):
|
|
175
|
+
continue
|
|
176
|
+
out.append(hook)
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
#: File hook modules by resolved path: loaded once per process, like a
|
|
181
|
+
#: ``module:attr`` import, so a hook's module-level state (a login cache)
|
|
182
|
+
#: lives on between requests.
|
|
183
|
+
_file_modules: Dict[str, Any] = {}
|
|
184
|
+
_file_modules_lock = threading.Lock()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _file_module(path: Path) -> Any:
|
|
188
|
+
resolved = str(path.resolve())
|
|
189
|
+
with _file_modules_lock:
|
|
190
|
+
module = _file_modules.get(resolved)
|
|
191
|
+
if module is None:
|
|
192
|
+
digest = hashlib.sha1(resolved.encode("utf-8")).hexdigest()[:8]
|
|
193
|
+
name = "syshttp_hook_%s_%s" % (re.sub(r"\W", "_", path.stem), digest)
|
|
194
|
+
module_spec = importlib.util.spec_from_file_location(name, resolved)
|
|
195
|
+
if module_spec is None or module_spec.loader is None:
|
|
196
|
+
raise ConfigurationError("%s is not loadable as a Python module" % resolved)
|
|
197
|
+
module = importlib.util.module_from_spec(module_spec)
|
|
198
|
+
sys.modules[name] = module
|
|
199
|
+
try:
|
|
200
|
+
module_spec.loader.exec_module(module)
|
|
201
|
+
except BaseException:
|
|
202
|
+
sys.modules.pop(name, None)
|
|
203
|
+
raise
|
|
204
|
+
_file_modules[resolved] = module
|
|
205
|
+
return module
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def load_object(spec: str, what: str = "an object") -> Any:
|
|
209
|
+
"""The object ``module:attr`` (importable) or ``<file>.py:attr`` names;
|
|
210
|
+
``attr`` may be dotted. A file is loaded once per process, by its resolved
|
|
211
|
+
path. A malformed ``spec`` is a ``ConfigurationError`` naming ``what``; a
|
|
212
|
+
module or attribute that is not there raises as the import does."""
|
|
213
|
+
target, sep, attr = spec.rpartition(":")
|
|
214
|
+
if not sep or not target or not attr:
|
|
215
|
+
raise ConfigurationError("%s is written <module or file.py>:<name>, not %r" % (what, spec))
|
|
216
|
+
if target.endswith(".py") and Path(target).is_file():
|
|
217
|
+
module = _file_module(Path(target))
|
|
218
|
+
else:
|
|
219
|
+
module = importlib.import_module(target)
|
|
220
|
+
obj: Any = module
|
|
221
|
+
for part in attr.split("."):
|
|
222
|
+
obj = getattr(obj, part)
|
|
223
|
+
return obj
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_callable(spec: str) -> "PyHook":
|
|
227
|
+
"""``module:attr`` (importable) or ``<file>.py:attr``, which must be
|
|
228
|
+
callable (:func:`load_object`)."""
|
|
229
|
+
obj = load_object(spec, "a hook callable")
|
|
230
|
+
if not callable(obj):
|
|
231
|
+
raise ConfigurationError("%s is not callable" % spec)
|
|
232
|
+
return obj
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _bare_url(url: str) -> str:
|
|
236
|
+
"""``url`` as scheme, host, port and path: no userinfo, query or fragment."""
|
|
237
|
+
try:
|
|
238
|
+
parts = urlsplit(url)
|
|
239
|
+
host = parts.hostname or ""
|
|
240
|
+
if ":" in host:
|
|
241
|
+
host = "[%s]" % host
|
|
242
|
+
port = ":%d" % parts.port if parts.port else ""
|
|
243
|
+
except ValueError:
|
|
244
|
+
return "<unreadable URL>"
|
|
245
|
+
return (
|
|
246
|
+
"%s://%s%s%s" % (parts.scheme, host, port, parts.path) if parts.scheme else "%s%s%s" % (host, port, parts.path)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def call_py_hooks(
|
|
251
|
+
client: "HTTPClient",
|
|
252
|
+
method: str,
|
|
253
|
+
url: str,
|
|
254
|
+
kwargs: Dict[str, Any],
|
|
255
|
+
*,
|
|
256
|
+
env: Optional[HTTPEnv] = None,
|
|
257
|
+
) -> "Optional[HTTPResponse]":
|
|
258
|
+
"""Run every ``_PY`` hook matching ``url`` in order; the first non-None
|
|
259
|
+
result is returned (the caller uses it as the response), else ``None``."""
|
|
260
|
+
for hook in matching(url, kind="py", env=env):
|
|
261
|
+
if not hook.py: # `kind="py"` already filtered; for the type checker
|
|
262
|
+
continue
|
|
263
|
+
try:
|
|
264
|
+
fn = load_callable(hook.py)
|
|
265
|
+
result = fn(client, method, url, kwargs)
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
# The variable, so the failure is traceable to its declaration;
|
|
268
|
+
# the URL without its userinfo, query and fragment, which carry
|
|
269
|
+
# secrets. The hook's own exception is chained as it is.
|
|
270
|
+
raise HookError(
|
|
271
|
+
"%s%s (%s) failed for %s %s: %s" % (hook.variable, PY_SUFFIX, hook.py, method, _bare_url(url), exc)
|
|
272
|
+
) from exc
|
|
273
|
+
if result is not None:
|
|
274
|
+
return result
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def split_command(value: str) -> List[str]:
|
|
279
|
+
"""A shell-quoted command line into argv: single and double quotes group
|
|
280
|
+
words with spaces, a backslash is literal (no escaping on any platform,
|
|
281
|
+
so ``C:\\Tools\\wrap.exe`` stays what it is), ``#`` starts no comment."""
|
|
282
|
+
lex = shlex.shlex(value, posix=True)
|
|
283
|
+
lex.whitespace_split = True
|
|
284
|
+
lex.escape = ""
|
|
285
|
+
lex.commenters = ""
|
|
286
|
+
return list(lex)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def curl_command(hook: Hook, python: Optional[str] = None) -> List[str]:
|
|
290
|
+
"""The argv prefix a ``_CURL`` target means, the front end's argv to be
|
|
291
|
+
appended: the shell-quoted command line split; a bare path to an existing
|
|
292
|
+
file (spaces and all) is that one program; a first word that is a ``.py``
|
|
293
|
+
file runs under ``python`` (default: this interpreter)."""
|
|
294
|
+
value = (hook.curl or "").strip()
|
|
295
|
+
if not value:
|
|
296
|
+
raise ConfigurationError("%s%s is empty" % (hook.variable, CURL_SUFFIX))
|
|
297
|
+
argv = [value] if Path(value).is_file() else split_command(value)
|
|
298
|
+
if not argv:
|
|
299
|
+
raise ConfigurationError("%s%s holds no command" % (hook.variable, CURL_SUFFIX))
|
|
300
|
+
if argv[0].lower().endswith(".py") and Path(argv[0]).is_file():
|
|
301
|
+
argv = [python or sys.executable, *argv]
|
|
302
|
+
return argv
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def wrapper_environment(hook: Hook, url: str, entry: str, *, env: Optional[HTTPEnv] = None) -> Dict[str, str]:
|
|
306
|
+
"""The variables a ``_CURL`` wrapper run adds to its environment:
|
|
307
|
+
``SYSHTTP_HOOK_URL`` (``url``), ``SYSHTTP_HOOK_KEY``, ``SYSHTTP_HOOK_CURL``
|
|
308
|
+
(``entry``, the front end to call back) and ``SYSHTTP_HOOK_SKIP`` with this
|
|
309
|
+
KEY added, so the call back does not run the wrapper again; each under the
|
|
310
|
+
first prefix."""
|
|
311
|
+
env = _env(env)
|
|
312
|
+
url_var, key_var, curl_var, skip_var = (env.name(s) for s in (HOOK_URL, HOOK_KEY, HOOK_CURL, HOOK_SKIP))
|
|
313
|
+
skip = ",".join(sorted(skipped(env=env) | {hook.key.upper()}))
|
|
314
|
+
return {url_var: url, key_var: hook.key, curl_var: entry, skip_var: skip}
|
syshttp/jar.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""File-backed cookie/token jars, keyed by host.
|
|
2
|
+
|
|
3
|
+
Jars live under one root, :func:`store_root`: ``SYSHTTP_HOME`` (under the
|
|
4
|
+
prefixes of :mod:`syshttp.env`), else the platform's per-user state
|
|
5
|
+
directory. Cookies go under ``<root>/cookies/<host>.txt`` (Netscape format),
|
|
6
|
+
tokens under ``<root>/tokens/<host>.token``.
|
|
7
|
+
|
|
8
|
+
Token values are secrets: this module never logs or prints them. Files are
|
|
9
|
+
written ``0o600`` (directories ``0o700``) and replaced whole, with LF line
|
|
10
|
+
ends; a directory is created at the first write, not when a jar is built.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Iterable, Optional, Protocol
|
|
18
|
+
|
|
19
|
+
from ._fileio import read_text, write_private
|
|
20
|
+
from .cookies import CookieSpec, load_netscape, save_netscape
|
|
21
|
+
from .env import HTTPEnv
|
|
22
|
+
|
|
23
|
+
__all__ = (
|
|
24
|
+
"CookieJar",
|
|
25
|
+
"FileCookieJar",
|
|
26
|
+
"FileTokenJar",
|
|
27
|
+
"HOME",
|
|
28
|
+
"MemoryCookieJar",
|
|
29
|
+
"MemoryTokenJar",
|
|
30
|
+
"TokenJar",
|
|
31
|
+
"default_root",
|
|
32
|
+
"safe_name",
|
|
33
|
+
"store_root",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
#: The setting naming the jar root.
|
|
37
|
+
HOME = "SYSHTTP_HOME"
|
|
38
|
+
|
|
39
|
+
_UNSAFE = '<>:"/\\|?*'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def safe_name(key: str) -> str:
|
|
43
|
+
"""A jar file name for a host: characters no filesystem takes (``:`` of an
|
|
44
|
+
IPv6 literal on Windows, a ``/``) become ``_``."""
|
|
45
|
+
return "".join("_" if ch in _UNSAFE else ch for ch in key)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def default_root(*, env: Optional[HTTPEnv] = None) -> Path:
|
|
49
|
+
"""The per-user state directory for jars when ``SYSHTTP_HOME`` is unset, a
|
|
50
|
+
directory ``syshttp`` under: ``%LOCALAPPDATA%`` on Windows,
|
|
51
|
+
``~/Library/Application Support`` on macOS, ``$XDG_STATE_HOME`` (else
|
|
52
|
+
``~/.local/state``) elsewhere."""
|
|
53
|
+
env = HTTPEnv() if env is None else env
|
|
54
|
+
if sys.platform == "win32":
|
|
55
|
+
base = (env.environ.get("LOCALAPPDATA") or "").strip()
|
|
56
|
+
return (Path(base) if base else Path.home() / "AppData" / "Local") / "syshttp"
|
|
57
|
+
if sys.platform == "darwin":
|
|
58
|
+
return Path.home() / "Library" / "Application Support" / "syshttp"
|
|
59
|
+
base = (env.environ.get("XDG_STATE_HOME") or "").strip()
|
|
60
|
+
return (Path(base) if base else Path.home() / ".local" / "state") / "syshttp"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def store_root(*, env: Optional[HTTPEnv] = None) -> Path:
|
|
64
|
+
"""The jar root: ``SYSHTTP_HOME``, else :func:`default_root`."""
|
|
65
|
+
env = HTTPEnv() if env is None else env
|
|
66
|
+
home = env.get(HOME)
|
|
67
|
+
return Path(home).expanduser() if home else default_root(env=env)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CookieJar(Protocol):
|
|
71
|
+
def __getitem__(self, key: str) -> Iterable[CookieSpec]: ...
|
|
72
|
+
def __setitem__(self, key: str, value: Iterable[CookieSpec]) -> None: ...
|
|
73
|
+
def get(self, key: str, default=None): ...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TokenJar(Protocol):
|
|
77
|
+
def __getitem__(self, key: str) -> str: ...
|
|
78
|
+
def __setitem__(self, key: str, value: str) -> None: ...
|
|
79
|
+
def get(self, key: str, default=None): ...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FileCookieJar:
|
|
83
|
+
def __init__(self, root: "Path | None" = None, *, env: Optional[HTTPEnv] = None):
|
|
84
|
+
self.root = Path(root) if root is not None else store_root(env=env)
|
|
85
|
+
self.dir = self.root / "cookies"
|
|
86
|
+
|
|
87
|
+
def _path(self, key: str) -> Path:
|
|
88
|
+
return self.dir / ("%s.txt" % safe_name(key))
|
|
89
|
+
|
|
90
|
+
def __getitem__(self, key: str) -> Iterable[CookieSpec]:
|
|
91
|
+
return load_netscape(self._path(key))
|
|
92
|
+
|
|
93
|
+
def __setitem__(self, key: str, value: Iterable[CookieSpec]) -> None:
|
|
94
|
+
save_netscape(value, self._path(key))
|
|
95
|
+
|
|
96
|
+
def get(self, key: str, default=None):
|
|
97
|
+
try:
|
|
98
|
+
return self[key]
|
|
99
|
+
except Exception:
|
|
100
|
+
return default
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FileTokenJar:
|
|
104
|
+
def __init__(self, root: "Path | None" = None, *, env: Optional[HTTPEnv] = None):
|
|
105
|
+
self.root = Path(root) if root is not None else store_root(env=env)
|
|
106
|
+
self.dir = self.root / "tokens"
|
|
107
|
+
|
|
108
|
+
def _path(self, key: str) -> Path:
|
|
109
|
+
return self.dir / ("%s.token" % safe_name(key))
|
|
110
|
+
|
|
111
|
+
def __getitem__(self, key: str) -> str:
|
|
112
|
+
# Never logs the value read (secret).
|
|
113
|
+
return read_text(self._path(key)).strip()
|
|
114
|
+
|
|
115
|
+
def __setitem__(self, key: str, value: str) -> None:
|
|
116
|
+
write_private(self._path(key), (value.strip() + "\n").encode("utf-8"))
|
|
117
|
+
|
|
118
|
+
def get(self, key: str, default=None):
|
|
119
|
+
try:
|
|
120
|
+
v = self[key]
|
|
121
|
+
return v if v else default
|
|
122
|
+
except Exception:
|
|
123
|
+
return default
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class MemoryCookieJar(dict):
|
|
127
|
+
def __getitem__(self, key: str) -> Iterable[CookieSpec]:
|
|
128
|
+
return super().get(key, [])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class MemoryTokenJar(dict):
|
|
132
|
+
def __getitem__(self, key: str) -> str:
|
|
133
|
+
return super().__getitem__(key)
|