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/proxy.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Proxy routing: syshttp's settings as one proxylib proxy map.
|
|
2
|
+
|
|
3
|
+
Settings are read through an :class:`~syshttp.env.HTTPEnv` (``env=``, default
|
|
4
|
+
``HTTPEnv()``), each name under each of its prefixes (``SYSHTTP_PREFIX``);
|
|
5
|
+
below they are named as written, under the empty prefix.
|
|
6
|
+
|
|
7
|
+
:func:`proxy_map` is a proxylib ``ProxyMap`` that reads the settings at every
|
|
8
|
+
lookup and answers, in order:
|
|
9
|
+
|
|
10
|
+
1. **``SYSHTTP_PROXY``** -- a proxy for this package's traffic, distinct from
|
|
11
|
+
the machine's so other tools (git, npm, corporate CLIs) are not forced
|
|
12
|
+
through it. Its credential is ``SYSHTTP_PROXY_AUTH`` (a header value sent
|
|
13
|
+
verbatim, in ``SYSHTTP_PROXY_AUTH_HEADER``, default
|
|
14
|
+
``Proxy-Authorization``), else the URL's userinfo as Basic.
|
|
15
|
+
``SYSHTTP_PROXY_TYPE=prefix[:/endpoint]`` makes it a
|
|
16
|
+
URL-prefix gateway: ``GET https://api.example.com/x`` becomes
|
|
17
|
+
``GET <proxy><endpoint>https://api.example.com/x``.
|
|
18
|
+
2. **The machine's proxy**: the ``*_PROXY`` variables when any is set (per
|
|
19
|
+
scheme, as proxylib's ``EnvProxyConfig`` reads them, the lowercase
|
|
20
|
+
spelling first under each prefix), else the system's configuration for
|
|
21
|
+
the URL (WinHTTP on Windows, CFNetwork on macOS, libproxy or the desktop's
|
|
22
|
+
settings elsewhere; PAC scripts and WPAD included), unless
|
|
23
|
+
``SYSHTTP_SYSTEM_PROXY`` is off. ``HTTP_PROXY_AUTH`` is its credential
|
|
24
|
+
(``Proxy-Authorization``).
|
|
25
|
+
3. **DIRECT.**
|
|
26
|
+
|
|
27
|
+
``NO_PROXY`` / ``no_proxy`` hosts go direct before any of it (proxylib's
|
|
28
|
+
``BypassRules``: suffixes, CIDR, ports, loopback). An answer is a list
|
|
29
|
+
(``PROXY a; PROXY b; DIRECT``): proxylib's client integrations try it in
|
|
30
|
+
order and raise the last error when no entry connects; a client that sends
|
|
31
|
+
through one proxy only (urllib, a curl command line) takes it through
|
|
32
|
+
``proxylib.Probed``, which raises :class:`NoReachableProxyError`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import logging
|
|
38
|
+
import threading
|
|
39
|
+
import time
|
|
40
|
+
from typing import Any, Dict, Optional, Tuple
|
|
41
|
+
|
|
42
|
+
import proxylib
|
|
43
|
+
from proxylib import NoReachableProxyError, Proxy
|
|
44
|
+
|
|
45
|
+
from .env import HTTPEnv
|
|
46
|
+
from .exceptions import ConfigurationError
|
|
47
|
+
|
|
48
|
+
__all__ = (
|
|
49
|
+
"DEFAULT_AUTH_HEADER",
|
|
50
|
+
"MACHINE_AUTH_VAR",
|
|
51
|
+
"NoReachableProxyError",
|
|
52
|
+
"PROXY",
|
|
53
|
+
"PROXY_AUTH",
|
|
54
|
+
"PROXY_AUTH_HEADER",
|
|
55
|
+
"PROXY_TYPE",
|
|
56
|
+
"SYSTEM_PROXY",
|
|
57
|
+
"SYSTEM_PROXY_MAX_AGE",
|
|
58
|
+
"auth_header",
|
|
59
|
+
"configured_proxy",
|
|
60
|
+
"proxy_map",
|
|
61
|
+
"proxy_type",
|
|
62
|
+
"system_map",
|
|
63
|
+
"system_proxy_enabled",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
#: The settings this module reads, each under every prefix.
|
|
67
|
+
PROXY = "SYSHTTP_PROXY"
|
|
68
|
+
PROXY_AUTH = "SYSHTTP_PROXY_AUTH"
|
|
69
|
+
#: The header the credential rides in; default the standard one. A gateway
|
|
70
|
+
#: that wants it under another name (``X-Proxy-Token``) sets this.
|
|
71
|
+
PROXY_AUTH_HEADER = "SYSHTTP_PROXY_AUTH_HEADER"
|
|
72
|
+
#: How the proxy is spoken to: ``connect`` (default) or ``prefix[:/endpoint]``.
|
|
73
|
+
PROXY_TYPE = "SYSHTTP_PROXY_TYPE"
|
|
74
|
+
DEFAULT_AUTH_HEADER = "Proxy-Authorization"
|
|
75
|
+
#: Whether the system's proxy configuration is consulted, as a boolean
|
|
76
|
+
#: (``1``/``true``/``yes``/``on`` or their opposites; unset or empty: on).
|
|
77
|
+
SYSTEM_PROXY = "SYSHTTP_SYSTEM_PROXY"
|
|
78
|
+
#: The machine proxy's header value.
|
|
79
|
+
MACHINE_AUTH_VAR = "HTTP_PROXY_AUTH"
|
|
80
|
+
#: How long (seconds) the system's configuration, and its answer for one URL,
|
|
81
|
+
#: are reused before they are read again.
|
|
82
|
+
SYSTEM_PROXY_MAX_AGE = 60
|
|
83
|
+
|
|
84
|
+
_log = logging.getLogger(__name__)
|
|
85
|
+
_TRUE = ("1", "true", "yes", "on")
|
|
86
|
+
_FALSE = ("0", "false", "no", "off")
|
|
87
|
+
_system_lock = threading.Lock()
|
|
88
|
+
_system: Any = None
|
|
89
|
+
_system_at = 0.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _env(env: Optional[HTTPEnv]) -> HTTPEnv:
|
|
93
|
+
return HTTPEnv() if env is None else env
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _var(env: HTTPEnv, setting: str) -> str:
|
|
97
|
+
"""The variable to name in a message about ``setting``: the one set, else
|
|
98
|
+
the name under the first prefix."""
|
|
99
|
+
found = env.find(setting)
|
|
100
|
+
return found[0] if found else env.name(setting)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def system_proxy_enabled(*, env: Optional[HTTPEnv] = None) -> bool:
|
|
104
|
+
"""``SYSHTTP_SYSTEM_PROXY`` as a boolean, default on. Any other value than
|
|
105
|
+
``1``/``true``/``yes``/``on`` or ``0``/``false``/``no``/``off`` (any case)
|
|
106
|
+
is a ``ValueError`` naming the variable."""
|
|
107
|
+
env = _env(env)
|
|
108
|
+
raw = env.get(SYSTEM_PROXY)
|
|
109
|
+
if raw is None:
|
|
110
|
+
return True
|
|
111
|
+
if raw.lower() in _TRUE:
|
|
112
|
+
return True
|
|
113
|
+
if raw.lower() in _FALSE:
|
|
114
|
+
return False
|
|
115
|
+
raise ConfigurationError("%s must be one of %s, not %r" % (_var(env, SYSTEM_PROXY), "/".join(_TRUE + _FALSE), raw))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def auth_header(*, env: Optional[HTTPEnv] = None) -> str:
|
|
119
|
+
"""The name of the header ``SYSHTTP_PROXY_AUTH`` is sent in:
|
|
120
|
+
``SYSHTTP_PROXY_AUTH_HEADER``, else ``Proxy-Authorization``. A value with
|
|
121
|
+
a colon or whitespace is not a header name: a ``ValueError``."""
|
|
122
|
+
env = _env(env)
|
|
123
|
+
name = env.get(PROXY_AUTH_HEADER)
|
|
124
|
+
if not name:
|
|
125
|
+
return DEFAULT_AUTH_HEADER
|
|
126
|
+
if ":" in name or any(ch.isspace() for ch in name):
|
|
127
|
+
raise ConfigurationError("%s must be a header name, not %r" % (_var(env, PROXY_AUTH_HEADER), name))
|
|
128
|
+
return name
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def proxy_type(*, env: Optional[HTTPEnv] = None) -> Tuple[str, Optional[str]]:
|
|
132
|
+
"""``("connect", None)`` for a standard proxy, or ``("prefix", "/endpoint")``
|
|
133
|
+
for a URL-prefix gateway (endpoint defaults to ``/``), from
|
|
134
|
+
``SYSHTTP_PROXY_TYPE``. Anything else is a configuration error, raised."""
|
|
135
|
+
env = _env(env)
|
|
136
|
+
raw = env.get(PROXY_TYPE)
|
|
137
|
+
if not raw:
|
|
138
|
+
return "connect", None
|
|
139
|
+
kind, _, endpoint = raw.partition(":")
|
|
140
|
+
kind = kind.strip().lower()
|
|
141
|
+
if kind == "connect":
|
|
142
|
+
return "connect", None
|
|
143
|
+
if kind != "prefix":
|
|
144
|
+
raise ConfigurationError("%s must be 'connect' or 'prefix[:/endpoint]', not %r" % (_var(env, PROXY_TYPE), raw))
|
|
145
|
+
endpoint = endpoint.strip() or "/"
|
|
146
|
+
if not endpoint.startswith("/"):
|
|
147
|
+
endpoint = "/" + endpoint
|
|
148
|
+
return "prefix", endpoint
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _checked(variable: str, value: str) -> str:
|
|
152
|
+
"""``value``, the credential read from ``variable``, unless it holds a CR,
|
|
153
|
+
LF or NUL, which would end the header. The message names the variable,
|
|
154
|
+
never the value (a secret)."""
|
|
155
|
+
if any(ch in value for ch in "\r\n\0"):
|
|
156
|
+
raise ConfigurationError("%s must not contain a carriage return, line feed or NUL character" % variable)
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _with_credential(proxy: Proxy, header: str, value: str) -> Proxy:
|
|
161
|
+
"""``proxy`` sending ``value`` in ``header`` instead of its userinfo."""
|
|
162
|
+
headers = proxy.headers
|
|
163
|
+
headers[header] = value
|
|
164
|
+
return Proxy(proxy.scheme, proxy.host, proxy.port, path=proxy.path, headers=headers)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _with_user(proxy: Proxy, user: str) -> Proxy:
|
|
168
|
+
"""``proxy`` with curl's ``-U user:password`` as its only credential."""
|
|
169
|
+
name, _, password = user.partition(":")
|
|
170
|
+
return Proxy(proxy.scheme, proxy.host, proxy.port, name, password, path=proxy.path)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def configured_proxy(*, env: Optional[HTTPEnv] = None) -> Optional[Proxy]:
|
|
174
|
+
"""``SYSHTTP_PROXY`` as a proxylib ``Proxy`` with its credential and kind,
|
|
175
|
+
or ``None`` when it is unset. ``SYSHTTP_PROXY_AUTH`` replaces the URL's
|
|
176
|
+
userinfo; ``SYSHTTP_PROXY_TYPE=prefix`` makes a gateway. A malformed value
|
|
177
|
+
raises ``ValueError`` (``ProxyParseError`` for the URL)."""
|
|
178
|
+
env = _env(env)
|
|
179
|
+
raw = env.get(PROXY)
|
|
180
|
+
if not raw:
|
|
181
|
+
return None
|
|
182
|
+
found = Proxy.parse(raw)
|
|
183
|
+
kind, endpoint = proxy_type(env=env)
|
|
184
|
+
if kind == "prefix":
|
|
185
|
+
if found.scheme not in ("http", "https"):
|
|
186
|
+
raise ConfigurationError("%s=prefix needs an http or https %s" % (_var(env, PROXY_TYPE), _var(env, PROXY)))
|
|
187
|
+
found = Proxy("prefix+" + found.scheme, found.host, found.port, found.username, found.password, path=endpoint)
|
|
188
|
+
value = env.get(PROXY_AUTH)
|
|
189
|
+
if not value:
|
|
190
|
+
return found
|
|
191
|
+
return _with_credential(found, auth_header(env=env), _checked(_var(env, PROXY_AUTH), value))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
#: The conventional proxy variables, lower-cased.
|
|
195
|
+
_PROXY_VARIABLES = tuple(s + "_proxy" for s in ("http", "https", "ftp", "ws", "wss", "all"))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _proxy_environ(env: HTTPEnv) -> Dict[str, str]:
|
|
199
|
+
"""The conventional proxy variables as ``EnvProxyConfig`` reads them,
|
|
200
|
+
each found through ``env``'s prefixes (lowercase spelling first)."""
|
|
201
|
+
found = {}
|
|
202
|
+
for name in _PROXY_VARIABLES:
|
|
203
|
+
value = env.get(name, name.upper())
|
|
204
|
+
if value:
|
|
205
|
+
found[name] = value
|
|
206
|
+
return found
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class _Quiet:
|
|
210
|
+
"""The system's map with its failures as "no opinion": a configuration
|
|
211
|
+
that cannot be read or run is DIRECT, never a failed request."""
|
|
212
|
+
|
|
213
|
+
def __init__(self, inner: Any) -> None:
|
|
214
|
+
self.inner = inner
|
|
215
|
+
|
|
216
|
+
def __getitem__(self, url: str) -> Any:
|
|
217
|
+
try:
|
|
218
|
+
return proxylib.lookup(self.inner, url)
|
|
219
|
+
except KeyError:
|
|
220
|
+
raise
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
_log.debug("the system proxy configuration failed for a URL: %s", exc)
|
|
223
|
+
raise KeyError(url) from None
|
|
224
|
+
|
|
225
|
+
def close(self) -> None:
|
|
226
|
+
close = getattr(self.inner, "close", None)
|
|
227
|
+
if close is not None:
|
|
228
|
+
close()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def system_map() -> Any:
|
|
232
|
+
"""The system's proxy configuration as a proxylib ``ProxyMap``
|
|
233
|
+
(``proxylib.auto_proxy`` with no environment variables: the OS resolver,
|
|
234
|
+
then the OS settings), read once per :data:`SYSTEM_PROXY_MAX_AGE` seconds,
|
|
235
|
+
each URL's answer kept as long. ``None`` when it cannot be read (logged at
|
|
236
|
+
DEBUG). A URL it cannot answer raises ``KeyError``."""
|
|
237
|
+
global _system, _system_at
|
|
238
|
+
with _system_lock:
|
|
239
|
+
now = time.monotonic()
|
|
240
|
+
if _system_at and now - _system_at <= SYSTEM_PROXY_MAX_AGE:
|
|
241
|
+
return _system
|
|
242
|
+
try:
|
|
243
|
+
_system = proxylib.Cached(_Quiet(proxylib.auto_proxy(environ={})), ttl=SYSTEM_PROXY_MAX_AGE)
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
_log.debug("the system proxy configuration could not be read: %s", exc)
|
|
246
|
+
_system = None
|
|
247
|
+
_system_at = now
|
|
248
|
+
return _system
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _Credential:
|
|
252
|
+
"""Every proxy ``inner`` answers, given ``Proxy-Authorization: value``
|
|
253
|
+
(SOCKS entries, which take no header, and DIRECT as they are)."""
|
|
254
|
+
|
|
255
|
+
def __init__(self, inner: Any, value: str) -> None:
|
|
256
|
+
self.inner = inner
|
|
257
|
+
self.value = value
|
|
258
|
+
|
|
259
|
+
def __getitem__(self, url: str) -> Tuple[Optional[Proxy], ...]:
|
|
260
|
+
return tuple(
|
|
261
|
+
(
|
|
262
|
+
entry
|
|
263
|
+
if entry is None or entry.scheme.startswith("socks")
|
|
264
|
+
else _with_credential(entry, DEFAULT_AUTH_HEADER, self.value)
|
|
265
|
+
)
|
|
266
|
+
for entry in proxylib.lookup(self.inner, url)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class _SettingsMap:
|
|
271
|
+
""":func:`proxy_map`'s map: the settings read again at every lookup."""
|
|
272
|
+
|
|
273
|
+
def __init__(self, proxy: Optional[str], noproxy: Optional[str], proxy_user: Optional[str], env: Any) -> None:
|
|
274
|
+
self.proxy = proxy
|
|
275
|
+
self.noproxy = noproxy
|
|
276
|
+
self.proxy_user = proxy_user
|
|
277
|
+
self.env = env
|
|
278
|
+
|
|
279
|
+
def __repr__(self) -> str:
|
|
280
|
+
return "proxy_map(%s)" % ("proxy=%r" % Proxy.parse(self.proxy).url if self.proxy else "")
|
|
281
|
+
|
|
282
|
+
def _inner(self, env: HTTPEnv) -> Any:
|
|
283
|
+
if self.proxy is not None:
|
|
284
|
+
# curl's -x: always a plain proxy, its credential its own.
|
|
285
|
+
return proxylib.SimpleProxyMap(Proxy.parse(self.proxy) if self.proxy else None)
|
|
286
|
+
configured = configured_proxy(env=env)
|
|
287
|
+
if configured is not None:
|
|
288
|
+
return proxylib.SimpleProxyMap(configured)
|
|
289
|
+
environ = _proxy_environ(env)
|
|
290
|
+
machine: Any = None
|
|
291
|
+
if environ or not system_proxy_enabled(env=env):
|
|
292
|
+
# Its variables alone decide, as urllib.request.getproxies() has it;
|
|
293
|
+
# --noproxy replaces NO_PROXY in the variables' own map too.
|
|
294
|
+
no_proxy = env.get("no_proxy", "NO_PROXY")
|
|
295
|
+
if no_proxy:
|
|
296
|
+
environ["no_proxy"] = no_proxy
|
|
297
|
+
machine = proxylib.EnvProxyConfig(environ, no_proxy=self.noproxy)
|
|
298
|
+
else:
|
|
299
|
+
machine = system_map()
|
|
300
|
+
chain = proxylib.ChainProxyMap(*(m for m in (machine, proxylib.SimpleProxyMap()) if m is not None))
|
|
301
|
+
found = env.find(MACHINE_AUTH_VAR)
|
|
302
|
+
return _Credential(chain, _checked(*found)) if found else chain
|
|
303
|
+
|
|
304
|
+
def _no_proxy(self, env: HTTPEnv) -> Optional[str]:
|
|
305
|
+
return self.noproxy if self.noproxy is not None else env.get("no_proxy", "NO_PROXY")
|
|
306
|
+
|
|
307
|
+
def __getitem__(self, url: str) -> Tuple[Optional[Proxy], ...]:
|
|
308
|
+
env = _env(self.env)
|
|
309
|
+
inner = self._inner(env)
|
|
310
|
+
no_proxy = self._no_proxy(env)
|
|
311
|
+
if no_proxy:
|
|
312
|
+
inner = proxylib.Bypass(inner, no_proxy)
|
|
313
|
+
answer = proxylib.lookup(inner, url)
|
|
314
|
+
if self.proxy_user is not None:
|
|
315
|
+
answer = tuple(None if entry is None else _with_user(entry, self.proxy_user) for entry in answer)
|
|
316
|
+
return answer
|
|
317
|
+
|
|
318
|
+
def close(self) -> None:
|
|
319
|
+
"""Nothing to free: the system's map is shared."""
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def proxy_map(
|
|
323
|
+
proxy: Optional[str] = None,
|
|
324
|
+
noproxy: Optional[str] = None,
|
|
325
|
+
proxy_user: Optional[str] = None,
|
|
326
|
+
*,
|
|
327
|
+
env: Optional[HTTPEnv] = None,
|
|
328
|
+
) -> Any:
|
|
329
|
+
"""syshttp's routing as a proxylib ``ProxyMap`` (``proxylib.lookup(m,
|
|
330
|
+
url)`` reads it; every proxylib integration takes it). The settings are
|
|
331
|
+
read at every lookup, so one map serves a long-lived client. It always
|
|
332
|
+
answers (never ``KeyError``).
|
|
333
|
+
|
|
334
|
+
``proxy`` (curl's ``-x``) replaces the settings with that proxy, always a
|
|
335
|
+
plain one whose credential is its own userinfo, never the configured
|
|
336
|
+
``SYSHTTP_PROXY_AUTH``; ``""`` means direct. ``noproxy`` (``--noproxy``)
|
|
337
|
+
replaces ``NO_PROXY``. ``proxy_user`` (``-U user:password``) becomes the
|
|
338
|
+
credential of whichever proxy answers, replacing the configured one. A
|
|
339
|
+
malformed setting raises ``ValueError`` at lookup, only when it would be
|
|
340
|
+
used."""
|
|
341
|
+
return _SettingsMap(proxy, noproxy, proxy_user, env)
|
syshttp/py.typed
ADDED
|
File without changes
|
syshttp/retry.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""What counts as transient -- the one list the client's
|
|
2
|
+
retries and a curl front end's ``--retry`` both use. Dependency-free."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from typing import Iterable, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from ._parse import ascii_int, http_date
|
|
10
|
+
|
|
11
|
+
__all__ = (
|
|
12
|
+
"TRANSIENT_STATUSES",
|
|
13
|
+
"retry_after",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
#: The HTTP statuses curl calls transient: request timeout, too many
|
|
17
|
+
#: requests, and the 5xx a retry can fix.
|
|
18
|
+
TRANSIENT_STATUSES: Tuple[int, ...] = (408, 429, 500, 502, 503, 504)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def retry_after(headers: Iterable[Tuple[str, str]], now: Optional[float] = None) -> Optional[float]:
|
|
22
|
+
"""A response's ``Retry-After`` in seconds -- a number, or an HTTP date
|
|
23
|
+
from now -- or ``None`` when absent or unreadable. ``headers`` are
|
|
24
|
+
``(name, value)`` pairs."""
|
|
25
|
+
value = next((v for k, v in headers if k.lower() == "retry-after"), "").strip()
|
|
26
|
+
if not value:
|
|
27
|
+
return None
|
|
28
|
+
seconds = ascii_int(value)
|
|
29
|
+
if seconds is not None:
|
|
30
|
+
return float(seconds)
|
|
31
|
+
when = http_date(value)
|
|
32
|
+
if when is None:
|
|
33
|
+
return None
|
|
34
|
+
return max(0.0, when - (time.time() if now is None else now))
|
syshttp/tls.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The system's TLS client as an ``SSLContext`` -- what the client
|
|
2
|
+
(``syshttp.client``) and a curl front end both connect with.
|
|
3
|
+
|
|
4
|
+
With neither ``cafile`` nor ``capath`` the context is
|
|
5
|
+
``sslcontext.system_context()``: certificates are verified by the operating
|
|
6
|
+
system's own verifier on Python 3.10+ (CryptoAPI on Windows, the Security
|
|
7
|
+
framework on macOS, OpenSSL over the system's store elsewhere), so
|
|
8
|
+
intermediate fetching, enterprise roots and policy apply as they do for the
|
|
9
|
+
system's own clients; on 3.9 by OpenSSL over the default store. A server that
|
|
10
|
+
asks for a client certificate gets one from the OS store (Windows Personal
|
|
11
|
+
store and smart cards, the macOS keychains, the default PKCS#11 module), chosen
|
|
12
|
+
as a browser chooses, or none. A ``cafile`` / ``capath`` replaces the store: a
|
|
13
|
+
plain ``ssl`` context trusting only them. No ``certifi`` bundle is involved."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ssl
|
|
18
|
+
import time
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import sslcontext
|
|
22
|
+
|
|
23
|
+
__all__ = (
|
|
24
|
+
"OS_CONTEXT_MAX_AGE",
|
|
25
|
+
"new_os_context",
|
|
26
|
+
"os_ssl_context",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
#: How long the shared context is reused (seconds) before it is rebuilt, so a
|
|
30
|
+
#: certificate added to the system store is picked up.
|
|
31
|
+
OS_CONTEXT_MAX_AGE = 60
|
|
32
|
+
_os_context: "Optional[ssl.SSLContext]" = None
|
|
33
|
+
_os_context_at = 0.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def new_os_context(
|
|
37
|
+
cafile: Optional[str] = None, capath: Optional[str] = None, *, client_certificates: bool = True
|
|
38
|
+
) -> "ssl.SSLContext":
|
|
39
|
+
"""A fresh verifying client context: on ``cafile`` / ``capath`` when given
|
|
40
|
+
(they replace the OS store), else the system's (``sslcontext.system_context``).
|
|
41
|
+
``client_certificates=False`` never offers a certificate from the OS store:
|
|
42
|
+
pass it before loading a client certificate of your own, which a store
|
|
43
|
+
certificate would otherwise replace."""
|
|
44
|
+
if cafile or capath:
|
|
45
|
+
return ssl.create_default_context(cafile=cafile, capath=capath)
|
|
46
|
+
return sslcontext.system_context(selector=None if client_certificates else False)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def os_ssl_context() -> "ssl.SSLContext":
|
|
50
|
+
"""The system's ``SSLContext`` (:func:`new_os_context`), shared for
|
|
51
|
+
:data:`OS_CONTEXT_MAX_AGE` seconds. Shared, because urllib3 keys its
|
|
52
|
+
connection pools by the context object: one per request would never reuse
|
|
53
|
+
a connection. Never load a client certificate into it: use
|
|
54
|
+
:func:`new_os_context` with ``client_certificates=False``."""
|
|
55
|
+
global _os_context, _os_context_at
|
|
56
|
+
now = time.monotonic()
|
|
57
|
+
if _os_context is None or now - _os_context_at > OS_CONTEXT_MAX_AGE:
|
|
58
|
+
_os_context = new_os_context()
|
|
59
|
+
_os_context_at = now
|
|
60
|
+
return _os_context
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: syshttp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTTP plumbing that follows the system: the system proxy (PAC, WPAD) and the OS certificate verifier by default, proxy credentials and prefix gateways, Netscape cookie jars, OS trust-store TLS, URL hooks, and an HTTP client on the standard library
|
|
5
|
+
Project-URL: Homepage, https://github.com/jose-pr/syshttp
|
|
6
|
+
Project-URL: Documentation, https://jose-pr.github.io/syshttp/
|
|
7
|
+
Project-URL: Issues, https://github.com/jose-pr/syshttp/issues
|
|
8
|
+
Author-email: jose-pr <jose-pr@coqui.dev>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Requires-Dist: importpatch<0.3,>=0.2.1
|
|
24
|
+
Requires-Dist: proxylib<3,>=2.0.2
|
|
25
|
+
Requires-Dist: sslcontext<3,>=2.1.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: aiohttp; extra == 'dev'
|
|
28
|
+
Requires-Dist: black; extra == 'dev'
|
|
29
|
+
Requires-Dist: build; extra == 'dev'
|
|
30
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
31
|
+
Requires-Dist: mypy<2; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
33
|
+
Requires-Dist: requests; extra == 'dev'
|
|
34
|
+
Requires-Dist: types-requests; extra == 'dev'
|
|
35
|
+
Provides-Extra: docs
|
|
36
|
+
Requires-Dist: mkdocs; extra == 'docs'
|
|
37
|
+
Requires-Dist: mkdocs-material; extra == 'docs'
|
|
38
|
+
Requires-Dist: mkdocstrings[python]; extra == 'docs'
|
|
39
|
+
Provides-Extra: pac
|
|
40
|
+
Requires-Dist: proxylib[pac]<3,>=2.0.2; extra == 'pac'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# syshttp
|
|
44
|
+
|
|
45
|
+
[](https://pypi.org/project/syshttp/)
|
|
46
|
+
[](https://pypi.org/project/syshttp/)
|
|
47
|
+
[](https://github.com/jose-pr/syshttp/blob/main/LICENSE)
|
|
48
|
+
[](https://jose-pr.github.io/syshttp/)
|
|
49
|
+
[](https://github.com/jose-pr/syshttp/actions/workflows/test.yml)
|
|
50
|
+
|
|
51
|
+
**HTTP plumbing that behaves the same in every client you build on it**:
|
|
52
|
+
which proxy to use, with which credential, for which hosts; how a prefix
|
|
53
|
+
gateway is spoken to; which cookies and tokens go with which origin; and TLS
|
|
54
|
+
through the operating system's own certificate verifier. By default it
|
|
55
|
+
routes the way the system does: the system's proxy configuration, PAC scripts
|
|
56
|
+
and WPAD included, unless a proxy variable says otherwise. An HTTP client on the
|
|
57
|
+
standard library puts all of them together; `requests` is needed by none of
|
|
58
|
+
it. Documentation: <https://jose-pr.github.io/syshttp/>.
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- **Routing is [proxylib](https://pypi.org/project/proxylib/)'s** —
|
|
63
|
+
`proxy.proxy_map()` turns the settings below into one proxylib proxy map,
|
|
64
|
+
and proxylib's client integrations send through it.
|
|
65
|
+
- **The system's proxy, by default** — with no proxy variable set, the
|
|
66
|
+
system's configuration decides per URL: WinHTTP on Windows, CFNetwork on
|
|
67
|
+
macOS, libproxy or the desktop's settings elsewhere, PAC and WPAD included.
|
|
68
|
+
A list (`PROXY a; PROXY b; DIRECT`) is tried in order, the next entry when
|
|
69
|
+
one does not connect; with no proxy connecting and no DIRECT the request
|
|
70
|
+
fails rather than going direct.
|
|
71
|
+
- **Proxies with credentials** — a proxy URL from `SYSHTTP_PROXY`, else the
|
|
72
|
+
usual `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` for the request's scheme.
|
|
73
|
+
The credential is a header value sent verbatim to that proxy only, so any
|
|
74
|
+
scheme works (`Basic`, `Bearer`, `Negotiate`). `NO_PROXY` is honoured
|
|
75
|
+
(CIDR included), and every redirect hop is decided again.
|
|
76
|
+
- **Prefix gateways** — `SYSHTTP_PROXY_TYPE=prefix:/fetch/` sends
|
|
77
|
+
`GET https://api.example.com/x` to `<proxy>/fetch/https://api.example.com/x`
|
|
78
|
+
(the client, requests, httpx and urllib; the client sends an `https:` origin
|
|
79
|
+
through an `https` gateway only). Responses, redirects and cookies still use
|
|
80
|
+
the caller's URL.
|
|
81
|
+
- **Prefixes of a tool's own** — `SYSHTTP_PREFIX=MYTOOL_` makes the package
|
|
82
|
+
read `MYTOOL_HTTPS_PROXY`, `MYTOOL_SYSHTTP_HOME` and so on instead of the
|
|
83
|
+
defaults; `MYTOOL_:` falls back to them, variable by variable. An
|
|
84
|
+
`HTTPEnv("MYTOOL_")` object keeps one configuration apart from another in
|
|
85
|
+
the same process.
|
|
86
|
+
- **Cookie and token jars** — Netscape cookie files with curl's matching
|
|
87
|
+
rules, plus per-host token files. Both are chosen by the origin's host,
|
|
88
|
+
never by the proxy's or the gateway's.
|
|
89
|
+
- **URL hooks** — `SYSHTTP_HOOKS_<KEY>=<regex>` plus `_PY=module:callable`
|
|
90
|
+
runs your code before matching requests: a login, a signature, a canned
|
|
91
|
+
response.
|
|
92
|
+
- **The system's TLS client, or your own TLS context** — by default
|
|
93
|
+
[sslcontext](https://pypi.org/project/sslcontext/)'s system context: the
|
|
94
|
+
operating system verifies certificates (CryptoAPI, the Security framework),
|
|
95
|
+
with no `certifi` bundle involved, and a server that asks for a client
|
|
96
|
+
certificate gets one from the OS store (smart cards included), chosen as a
|
|
97
|
+
browser chooses. Or `HTTPClient(ssl_context=...)` for a given identity, a
|
|
98
|
+
cipher list or a pinned protocol version.
|
|
99
|
+
- **An HTTP client on the standard library** — `HTTPClient` and `fetch()`:
|
|
100
|
+
retry with backoff, redirects that keep a credential with its origin, and
|
|
101
|
+
all of the above, on `http.client` and `ssl` alone. Its transport is
|
|
102
|
+
replaceable: any object with `send()` (a `requests` or `httpx` one, a fake
|
|
103
|
+
in a test), chosen per client or by `SYSHTTP_DEFAULT_TRANSPORT`.
|
|
104
|
+
- **Other programs' clients, patched on import** — `SYSHTTP_IMPORTPATCH=1`
|
|
105
|
+
(or `requests:urllib:httpx:aiohttp:pip`) makes those libraries route and
|
|
106
|
+
verify the same way in any program of the environment, through
|
|
107
|
+
[importpatch](https://pypi.org/project/importpatch/); what a caller sets
|
|
108
|
+
itself still wins.
|
|
109
|
+
|
|
110
|
+
## Installation
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install syshttp
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Extra | Adds | Needed for |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `pac` | a JavaScript engine for proxylib | PAC scripts proxylib evaluates itself (Linux without libproxy) |
|
|
119
|
+
|
|
120
|
+
syshttp depends on proxylib (the system proxy), sslcontext (the system's
|
|
121
|
+
TLS client: the OS verifier through truststore on Python 3.10+, OpenSSL over
|
|
122
|
+
the default store on 3.9) and importpatch (its `.pth` hooks imports at
|
|
123
|
+
interpreter start; nothing is patched unless `SYSHTTP_IMPORTPATCH` says so).
|
|
124
|
+
|
|
125
|
+
## Quick start
|
|
126
|
+
|
|
127
|
+
Ask where a request would go, the way every client here does:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
import proxylib
|
|
131
|
+
from syshttp import proxy
|
|
132
|
+
|
|
133
|
+
for entry in proxylib.lookup(proxy.proxy_map(), "https://api.example.com/"):
|
|
134
|
+
print("DIRECT" if entry is None else entry.url) # tried in this order
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
One request, routed and verified the way the system does (no extra to install):
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from syshttp import fetch
|
|
141
|
+
|
|
142
|
+
response = fetch("https://api.example.com/thing")
|
|
143
|
+
print(response.status, response.json())
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A client with file-backed cookies and tokens, kept across requests:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from syshttp.client import HTTPClient
|
|
150
|
+
|
|
151
|
+
with HTTPClient(cookies=True, tokens=True) as client:
|
|
152
|
+
response = client.request("GET", "https://api.example.com/thing")
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A separate configuration, isolated from the process environment:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from syshttp import HTTPEnv, fetch
|
|
159
|
+
|
|
160
|
+
env = HTTPEnv("MYTOOL_", environ={"MYTOOL_SYSHTTP_PROXY": "http://proxy.internal:3128"})
|
|
161
|
+
response = fetch("https://api.example.com/thing", env=env)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## API overview
|
|
165
|
+
|
|
166
|
+
| Module | Purpose |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| `syshttp.env` | `HTTPEnv`: the prefixes and mapping every setting is read from |
|
|
169
|
+
| `syshttp.proxy` | `proxy_map()`: the settings as one proxylib proxy map |
|
|
170
|
+
| `syshttp.client` | `HTTPClient` and `fetch()`: the HTTP client, its transport protocol and response |
|
|
171
|
+
| `syshttp.jar` | file and memory cookie/token jars |
|
|
172
|
+
| `syshttp.cookies` | Netscape cookie files and curl's cookie rules |
|
|
173
|
+
| `syshttp.hooks` | URL hooks declared in the environment |
|
|
174
|
+
| `syshttp.tls` | the OS verifier as an `SSLContext` |
|
|
175
|
+
| `syshttp.autopatch` | requests, urllib, httpx, aiohttp and pip patched on import |
|
|
176
|
+
| `syshttp.retry` | the transient statuses, `Retry-After` |
|
|
177
|
+
|
|
178
|
+
## Settings
|
|
179
|
+
|
|
180
|
+
Every setting is read as `<PREFIX><NAME>` for each prefix of
|
|
181
|
+
`SYSHTTP_PREFIX` (`:`-separated; default none, the names as written). The
|
|
182
|
+
full list is on the docs site:
|
|
183
|
+
<https://jose-pr.github.io/syshttp/settings/>.
|
|
184
|
+
|
|
185
|
+
| Variable | Sets |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `SYSHTTP_PREFIX` | the prefixes every other setting is read under |
|
|
188
|
+
| `SYSHTTP_PROXY` | the proxy URL; else `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, else the system's |
|
|
189
|
+
| `SYSHTTP_SYSTEM_PROXY` | `0` to stop consulting the system's proxy configuration |
|
|
190
|
+
| `SYSHTTP_PROXY_AUTH` | the proxy credential, sent verbatim; else `HTTP_PROXY_AUTH` |
|
|
191
|
+
| `SYSHTTP_PROXY_AUTH_HEADER` | the header it is sent in (default `Proxy-Authorization`) |
|
|
192
|
+
| `SYSHTTP_PROXY_TYPE` | `connect` (default) or `prefix[:/endpoint]` |
|
|
193
|
+
| `SYSHTTP_HOME` | the jar directory (default: the per-user state directory) |
|
|
194
|
+
| `SYSHTTP_IMPORTPATCH` | `1`, or `:`-separated names: the client libraries patched on import |
|
|
195
|
+
| `SYSHTTP_HOOKS_<KEY>` | a URL hook's pattern, with `_PY` / `_CURL` targets |
|
|
196
|
+
| `SYSHTTP_DEFAULT_TRANSPORT` | `module:attr` or `<file>.py:attr`: the transport of a client given none (it names code to import) |
|
|
197
|
+
| `CA_BUNDLE` | the CA bundle a client verifies with (default: the OS verifier) |
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
dotagents pyvenv # or any Python 3.9+ virtual environment
|
|
203
|
+
.pyvenv/<tag>/Scripts/python -m pip install -e ".[dev]"
|
|
204
|
+
.pyvenv/<tag>/Scripts/python -m pytest -q
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Releasing
|
|
208
|
+
|
|
209
|
+
This project follows [Semantic Versioning](https://semver.org/) and keeps a
|
|
210
|
+
[`CHANGELOG.md`](https://github.com/jose-pr/syshttp/blob/main/CHANGELOG.md).
|
|
211
|
+
Pushing a tag matching `v*` triggers the release workflow: test gate → build
|
|
212
|
+
(checking the tag names the version built) → a strict docs build as a gate →
|
|
213
|
+
GitHub release → publish. The release workflow never deploys the docs site
|
|
214
|
+
itself: for a final release its last job dispatches the docs workflow at the
|
|
215
|
+
tag, which owns every Pages deploy.
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT — see [LICENSE](https://github.com/jose-pr/syshttp/blob/main/LICENSE).
|