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/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)