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/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # syshttp
2
+
3
+ [![Version](https://img.shields.io/pypi/v/syshttp.svg)](https://pypi.org/project/syshttp/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/syshttp.svg)](https://pypi.org/project/syshttp/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jose-pr/syshttp/blob/main/LICENSE)
6
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://jose-pr.github.io/syshttp/)
7
+ [![CI](https://img.shields.io/github/actions/workflow/status/jose-pr/syshttp/test.yml)](https://github.com/jose-pr/syshttp/actions/workflows/test.yml)
8
+
9
+ **HTTP plumbing that behaves the same in every client you build on it**:
10
+ which proxy to use, with which credential, for which hosts; how a prefix
11
+ gateway is spoken to; which cookies and tokens go with which origin; and TLS
12
+ through the operating system's own certificate verifier. By default it
13
+ routes the way the system does: the system's proxy configuration, PAC scripts
14
+ and WPAD included, unless a proxy variable says otherwise. An HTTP client on the
15
+ standard library puts all of them together; `requests` is needed by none of
16
+ it. Documentation: <https://jose-pr.github.io/syshttp/>.
17
+
18
+ ## Features
19
+
20
+ - **Routing is [proxylib](https://pypi.org/project/proxylib/)'s** —
21
+ `proxy.proxy_map()` turns the settings below into one proxylib proxy map,
22
+ and proxylib's client integrations send through it.
23
+ - **The system's proxy, by default** — with no proxy variable set, the
24
+ system's configuration decides per URL: WinHTTP on Windows, CFNetwork on
25
+ macOS, libproxy or the desktop's settings elsewhere, PAC and WPAD included.
26
+ A list (`PROXY a; PROXY b; DIRECT`) is tried in order, the next entry when
27
+ one does not connect; with no proxy connecting and no DIRECT the request
28
+ fails rather than going direct.
29
+ - **Proxies with credentials** — a proxy URL from `SYSHTTP_PROXY`, else the
30
+ usual `HTTPS_PROXY` / `HTTP_PROXY` / `ALL_PROXY` for the request's scheme.
31
+ The credential is a header value sent verbatim to that proxy only, so any
32
+ scheme works (`Basic`, `Bearer`, `Negotiate`). `NO_PROXY` is honoured
33
+ (CIDR included), and every redirect hop is decided again.
34
+ - **Prefix gateways** — `SYSHTTP_PROXY_TYPE=prefix:/fetch/` sends
35
+ `GET https://api.example.com/x` to `<proxy>/fetch/https://api.example.com/x`
36
+ (the client, requests, httpx and urllib; the client sends an `https:` origin
37
+ through an `https` gateway only). Responses, redirects and cookies still use
38
+ the caller's URL.
39
+ - **Prefixes of a tool's own** — `SYSHTTP_PREFIX=MYTOOL_` makes the package
40
+ read `MYTOOL_HTTPS_PROXY`, `MYTOOL_SYSHTTP_HOME` and so on instead of the
41
+ defaults; `MYTOOL_:` falls back to them, variable by variable. An
42
+ `HTTPEnv("MYTOOL_")` object keeps one configuration apart from another in
43
+ the same process.
44
+ - **Cookie and token jars** — Netscape cookie files with curl's matching
45
+ rules, plus per-host token files. Both are chosen by the origin's host,
46
+ never by the proxy's or the gateway's.
47
+ - **URL hooks** — `SYSHTTP_HOOKS_<KEY>=<regex>` plus `_PY=module:callable`
48
+ runs your code before matching requests: a login, a signature, a canned
49
+ response.
50
+ - **The system's TLS client, or your own TLS context** — by default
51
+ [sslcontext](https://pypi.org/project/sslcontext/)'s system context: the
52
+ operating system verifies certificates (CryptoAPI, the Security framework),
53
+ with no `certifi` bundle involved, and a server that asks for a client
54
+ certificate gets one from the OS store (smart cards included), chosen as a
55
+ browser chooses. Or `HTTPClient(ssl_context=...)` for a given identity, a
56
+ cipher list or a pinned protocol version.
57
+ - **An HTTP client on the standard library** — `HTTPClient` and `fetch()`:
58
+ retry with backoff, redirects that keep a credential with its origin, and
59
+ all of the above, on `http.client` and `ssl` alone. Its transport is
60
+ replaceable: any object with `send()` (a `requests` or `httpx` one, a fake
61
+ in a test), chosen per client or by `SYSHTTP_DEFAULT_TRANSPORT`.
62
+ - **Other programs' clients, patched on import** — `SYSHTTP_IMPORTPATCH=1`
63
+ (or `requests:urllib:httpx:aiohttp:pip`) makes those libraries route and
64
+ verify the same way in any program of the environment, through
65
+ [importpatch](https://pypi.org/project/importpatch/); what a caller sets
66
+ itself still wins.
67
+
68
+ ## Installation
69
+
70
+ ```bash
71
+ pip install syshttp
72
+ ```
73
+
74
+ | Extra | Adds | Needed for |
75
+ | --- | --- | --- |
76
+ | `pac` | a JavaScript engine for proxylib | PAC scripts proxylib evaluates itself (Linux without libproxy) |
77
+
78
+ syshttp depends on proxylib (the system proxy), sslcontext (the system's
79
+ TLS client: the OS verifier through truststore on Python 3.10+, OpenSSL over
80
+ the default store on 3.9) and importpatch (its `.pth` hooks imports at
81
+ interpreter start; nothing is patched unless `SYSHTTP_IMPORTPATCH` says so).
82
+
83
+ ## Quick start
84
+
85
+ Ask where a request would go, the way every client here does:
86
+
87
+ ```python
88
+ import proxylib
89
+ from syshttp import proxy
90
+
91
+ for entry in proxylib.lookup(proxy.proxy_map(), "https://api.example.com/"):
92
+ print("DIRECT" if entry is None else entry.url) # tried in this order
93
+ ```
94
+
95
+ One request, routed and verified the way the system does (no extra to install):
96
+
97
+ ```python
98
+ from syshttp import fetch
99
+
100
+ response = fetch("https://api.example.com/thing")
101
+ print(response.status, response.json())
102
+ ```
103
+
104
+ A client with file-backed cookies and tokens, kept across requests:
105
+
106
+ ```python
107
+ from syshttp.client import HTTPClient
108
+
109
+ with HTTPClient(cookies=True, tokens=True) as client:
110
+ response = client.request("GET", "https://api.example.com/thing")
111
+ ```
112
+
113
+ A separate configuration, isolated from the process environment:
114
+
115
+ ```python
116
+ from syshttp import HTTPEnv, fetch
117
+
118
+ env = HTTPEnv("MYTOOL_", environ={"MYTOOL_SYSHTTP_PROXY": "http://proxy.internal:3128"})
119
+ response = fetch("https://api.example.com/thing", env=env)
120
+ ```
121
+
122
+ ## API overview
123
+
124
+ | Module | Purpose |
125
+ | --- | --- |
126
+ | `syshttp.env` | `HTTPEnv`: the prefixes and mapping every setting is read from |
127
+ | `syshttp.proxy` | `proxy_map()`: the settings as one proxylib proxy map |
128
+ | `syshttp.client` | `HTTPClient` and `fetch()`: the HTTP client, its transport protocol and response |
129
+ | `syshttp.jar` | file and memory cookie/token jars |
130
+ | `syshttp.cookies` | Netscape cookie files and curl's cookie rules |
131
+ | `syshttp.hooks` | URL hooks declared in the environment |
132
+ | `syshttp.tls` | the OS verifier as an `SSLContext` |
133
+ | `syshttp.autopatch` | requests, urllib, httpx, aiohttp and pip patched on import |
134
+ | `syshttp.retry` | the transient statuses, `Retry-After` |
135
+
136
+ ## Settings
137
+
138
+ Every setting is read as `<PREFIX><NAME>` for each prefix of
139
+ `SYSHTTP_PREFIX` (`:`-separated; default none, the names as written). The
140
+ full list is on the docs site:
141
+ <https://jose-pr.github.io/syshttp/settings/>.
142
+
143
+ | Variable | Sets |
144
+ | --- | --- |
145
+ | `SYSHTTP_PREFIX` | the prefixes every other setting is read under |
146
+ | `SYSHTTP_PROXY` | the proxy URL; else `HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY`, else the system's |
147
+ | `SYSHTTP_SYSTEM_PROXY` | `0` to stop consulting the system's proxy configuration |
148
+ | `SYSHTTP_PROXY_AUTH` | the proxy credential, sent verbatim; else `HTTP_PROXY_AUTH` |
149
+ | `SYSHTTP_PROXY_AUTH_HEADER` | the header it is sent in (default `Proxy-Authorization`) |
150
+ | `SYSHTTP_PROXY_TYPE` | `connect` (default) or `prefix[:/endpoint]` |
151
+ | `SYSHTTP_HOME` | the jar directory (default: the per-user state directory) |
152
+ | `SYSHTTP_IMPORTPATCH` | `1`, or `:`-separated names: the client libraries patched on import |
153
+ | `SYSHTTP_HOOKS_<KEY>` | a URL hook's pattern, with `_PY` / `_CURL` targets |
154
+ | `SYSHTTP_DEFAULT_TRANSPORT` | `module:attr` or `<file>.py:attr`: the transport of a client given none (it names code to import) |
155
+ | `CA_BUNDLE` | the CA bundle a client verifies with (default: the OS verifier) |
156
+
157
+ ## Development
158
+
159
+ ```bash
160
+ dotagents pyvenv # or any Python 3.9+ virtual environment
161
+ .pyvenv/<tag>/Scripts/python -m pip install -e ".[dev]"
162
+ .pyvenv/<tag>/Scripts/python -m pytest -q
163
+ ```
164
+
165
+ ### Releasing
166
+
167
+ This project follows [Semantic Versioning](https://semver.org/) and keeps a
168
+ [`CHANGELOG.md`](https://github.com/jose-pr/syshttp/blob/main/CHANGELOG.md).
169
+ Pushing a tag matching `v*` triggers the release workflow: test gate → build
170
+ (checking the tag names the version built) → a strict docs build as a gate →
171
+ GitHub release → publish. The release workflow never deploys the docs site
172
+ itself: for a final release its last job dispatches the docs workflow at the
173
+ tag, which owns every Pages deploy.
174
+
175
+ ## License
176
+
177
+ MIT — see [LICENSE](https://github.com/jose-pr/syshttp/blob/main/LICENSE).
syshttp/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """syshttp -- HTTP plumbing that follows the system, and an HTTP client on the
2
+ standard library that uses it.
3
+
4
+ Public surface (the package's AGENTS.md documents each name):
5
+ env.HTTPEnv — where settings come from: <PREFIX><NAME> for each
6
+ prefix of SYSHTTP_PREFIX (default: none)
7
+ proxy.proxy_map — the routing as a proxylib ProxyMap: SYSHTTP_PROXY with
8
+ its credential, else the *_PROXY variables or the
9
+ system's configuration, NO_PROXY first (-x / -U /
10
+ --noproxy overrides included)
11
+ proxy.configured_proxy / proxy_type — SYSHTTP_PROXY as a proxylib Proxy; SYSHTTP_PROXY_TYPE
12
+ (connect | prefix[:/endpoint])
13
+ client.HTTPClient / fetch — the client: proxy map, system TLS, cookies, tokens,
14
+ hooks, retries; replaceable transport. ``fetch`` is
15
+ also ``syshttp.fetch``
16
+ cookies.load_netscape / save_netscape / cookie_applies / set_cookie_specs
17
+ — Netscape jars and curl's cookie rules
18
+ tls.os_ssl_context / new_os_context — the system's TLS client (sslcontext) as an SSLContext
19
+ retry.TRANSIENT_STATUSES / retry_after — what counts as transient, Retry-After
20
+ jar.FileCookieJar / FileTokenJar — file-backed jars under SYSHTTP_HOME
21
+ hooks — SYSHTTP_HOOKS_<KEY> URL hooks (a curl wrapper,
22
+ a syshttp callable) matched on the caller's URL
23
+ autopatch — SYSHTTP_IMPORTPATCH: requests, urllib, httpx, aiohttp,
24
+ pip patched on import to route and verify the same way
25
+
26
+ Nothing here imports ``requests``: only ``autopatch`` patches it, in a program
27
+ that imports it. Importing ``syshttp`` itself loads neither proxylib nor the
28
+ client.
29
+ """
30
+
31
+ from importlib.metadata import PackageNotFoundError, version
32
+ from typing import Any
33
+
34
+ from .env import HTTPEnv
35
+ from .exceptions import ConfigurationError, HookError, ResponseError, SysHTTPError
36
+
37
+ __all__ = [
38
+ "ConfigurationError",
39
+ "HTTPEnv",
40
+ "HookError",
41
+ "NoReachableProxyError",
42
+ "ResponseError",
43
+ "SysHTTPError",
44
+ "configured_proxy",
45
+ "fetch",
46
+ "proxy_map",
47
+ "proxy_type",
48
+ ]
49
+
50
+ # Read from syshttp.proxy when first asked for: importing syshttp must not
51
+ # import proxylib, whose own import of urllib.request runs syshttp's
52
+ # import-time patch (syshttp.autopatch) while proxylib is half built.
53
+ _FROM_PROXY = ("NoReachableProxyError", "configured_proxy", "proxy_map", "proxy_type")
54
+
55
+
56
+ def __getattr__(name: str) -> Any:
57
+ if name in _FROM_PROXY:
58
+ from . import proxy
59
+
60
+ return getattr(proxy, name)
61
+ if name == "fetch":
62
+ from .client import fetch
63
+
64
+ return fetch
65
+ raise AttributeError("module %r has no attribute %r" % (__name__, name))
66
+
67
+
68
+ try:
69
+ __version__ = version("syshttp")
70
+ except PackageNotFoundError: # a source tree on sys.path, not installed
71
+ __version__ = "0+unknown"
syshttp/_fileio.py ADDED
@@ -0,0 +1,78 @@
1
+ """Writing files that hold secrets: owner-only and replaced whole."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ import time
8
+ from pathlib import Path
9
+
10
+ #: ``os.replace`` onto a file another handle has open fails with
11
+ #: ``PermissionError`` on Windows until that handle closes, which a reader
12
+ #: does within microseconds.
13
+ _REPLACE_TRIES = 10
14
+ _REPLACE_WAIT = 0.01
15
+
16
+
17
+ def _make_directories(directory: Path) -> None:
18
+ """Create ``directory`` and its missing parents with mode ``0o700``; a
19
+ directory that exists is left as it is."""
20
+ missing = []
21
+ while not directory.exists() and directory != directory.parent:
22
+ missing.append(directory)
23
+ directory = directory.parent
24
+ for created in reversed(missing):
25
+ try:
26
+ created.mkdir(mode=0o700)
27
+ except FileExistsError:
28
+ continue
29
+ try:
30
+ created.chmod(0o700)
31
+ except OSError: # a filesystem without modes
32
+ pass
33
+
34
+
35
+ def read_text(path: Path) -> str:
36
+ """The UTF-8 text of ``path`` (undecodable bytes dropped). On Windows a
37
+ file being replaced refuses to open for an instant: that is retried."""
38
+ for attempt in range(_REPLACE_TRIES):
39
+ try:
40
+ return Path(path).read_text(encoding="utf-8", errors="ignore")
41
+ except PermissionError:
42
+ if attempt == _REPLACE_TRIES - 1:
43
+ raise
44
+ time.sleep(_REPLACE_WAIT)
45
+ raise AssertionError("unreachable") # pragma: no cover
46
+
47
+
48
+ def _replace(source: str, target: Path) -> None:
49
+ for attempt in range(_REPLACE_TRIES):
50
+ try:
51
+ os.replace(source, target)
52
+ return
53
+ except PermissionError:
54
+ if attempt == _REPLACE_TRIES - 1:
55
+ raise
56
+ time.sleep(_REPLACE_WAIT)
57
+
58
+
59
+ def write_private(path: Path, data: bytes) -> None:
60
+ """Write ``data`` to ``path`` so that a reader sees the old file or the
61
+ new one, never part of either, and only the owner can read it (``0o600``;
62
+ missing parent directories are ``0o700``). The bytes are written as
63
+ given: no newline translation."""
64
+ path = Path(path)
65
+ _make_directories(path.parent)
66
+ fd, temporary = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp")
67
+ try:
68
+ with os.fdopen(fd, "wb") as handle:
69
+ handle.write(data)
70
+ handle.flush()
71
+ os.fsync(handle.fileno())
72
+ _replace(temporary, path)
73
+ except BaseException:
74
+ try:
75
+ os.unlink(temporary)
76
+ except OSError:
77
+ pass
78
+ raise
syshttp/_parse.py ADDED
@@ -0,0 +1,30 @@
1
+ """Readers for the numbers and dates that headers and cookie files carry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import email.utils
6
+ from datetime import timezone
7
+ from typing import Optional
8
+
9
+
10
+ def ascii_int(value: str) -> Optional[int]:
11
+ """``value`` as a non-negative integer when it is only the digits
12
+ ``0-9`` (``str.isdigit`` also accepts ``"²"``); else ``None``."""
13
+ value = value.strip()
14
+ return int(value) if value.isascii() and value.isdigit() else None
15
+
16
+
17
+ def http_date(value: str) -> Optional[float]:
18
+ """An HTTP date (IMF-fixdate, RFC 850 or asctime) as a unix time, or
19
+ ``None`` when unreadable. A date without a zone is UTC (RFC 9110
20
+ section 5.6.7)."""
21
+ try:
22
+ when = email.utils.parsedate_to_datetime(value)
23
+ except (TypeError, ValueError, IndexError, OverflowError):
24
+ return None
25
+ if when.tzinfo is None:
26
+ when = when.replace(tzinfo=timezone.utc)
27
+ try:
28
+ return when.timestamp()
29
+ except (ValueError, OverflowError, OSError):
30
+ return None
syshttp/auth.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol
4
+
5
+ if TYPE_CHECKING: # the client imports this module: types only
6
+ from .client import HTTPClient
7
+
8
+
9
+ __all__ = ("AuthProvider",)
10
+
11
+
12
+ class AuthProvider(Protocol):
13
+ """Something that makes ``client`` authenticated for ``url`` (a login,
14
+ a token refresh): ``HTTPClient`` calls it before each request, and
15
+ ``fetch(reauth=...)`` again after a 401 or 403."""
16
+
17
+ def ensure(self, client: "HTTPClient", url: str) -> None: ...