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/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
+ [![Version](https://img.shields.io/pypi/v/syshttp.svg)](https://pypi.org/project/syshttp/)
46
+ [![Python versions](https://img.shields.io/pypi/pyversions/syshttp.svg)](https://pypi.org/project/syshttp/)
47
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/syshttp/blob/main/LICENSE)
48
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/syshttp/)
49
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/syshttp/test.yml)](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).