headgate 0.2.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.
Files changed (80) hide show
  1. headgate/__init__.py +131 -0
  2. headgate/_attempts.py +50 -0
  3. headgate/_auth.py +504 -0
  4. headgate/_body.py +115 -0
  5. headgate/_client.py +927 -0
  6. headgate/_codec.py +24 -0
  7. headgate/_core/__init__.py +88 -0
  8. headgate/_core/_context.py +228 -0
  9. headgate/_core/_events.py +248 -0
  10. headgate/_core/_origin.py +39 -0
  11. headgate/_core/_retry_after.py +76 -0
  12. headgate/_core/_seams.py +50 -0
  13. headgate/_core/_switch.py +40 -0
  14. headgate/_core/_time.py +102 -0
  15. headgate/_curl.py +287 -0
  16. headgate/_deprecation.py +101 -0
  17. headgate/_failures.py +439 -0
  18. headgate/_middleware.py +90 -0
  19. headgate/_options.py +358 -0
  20. headgate/_redirects.py +86 -0
  21. headgate/_request_body.py +81 -0
  22. headgate/_responses.py +290 -0
  23. headgate/_stack.py +185 -0
  24. headgate/_url.py +258 -0
  25. headgate/_user_agent.py +30 -0
  26. headgate/_view.py +105 -0
  27. headgate/breaker/__init__.py +24 -0
  28. headgate/breaker/_breaker.py +302 -0
  29. headgate/breaker/_policy.py +37 -0
  30. headgate/breaker/_registry.py +168 -0
  31. headgate/breaker/_scope.py +43 -0
  32. headgate/cache/__init__.py +35 -0
  33. headgate/cache/_conditional.py +89 -0
  34. headgate/cache/_credentials.py +143 -0
  35. headgate/cache/_policy.py +131 -0
  36. headgate/cache/_transport.py +568 -0
  37. headgate/curl.py +8 -0
  38. headgate/errors.py +329 -0
  39. headgate/events.py +45 -0
  40. headgate/health.py +73 -0
  41. headgate/msgspec.py +125 -0
  42. headgate/otel.py +236 -0
  43. headgate/pagination/__init__.py +25 -0
  44. headgate/pagination/_link.py +141 -0
  45. headgate/pagination/_pager.py +197 -0
  46. headgate/pagination/_paginated.py +82 -0
  47. headgate/policy.py +43 -0
  48. headgate/py.typed +0 -0
  49. headgate/pydantic.py +131 -0
  50. headgate/ratelimit/__init__.py +23 -0
  51. headgate/ratelimit/_budget.py +275 -0
  52. headgate/ratelimit/_budgets.py +62 -0
  53. headgate/ratelimit/_feedback.py +133 -0
  54. headgate/ratelimit/_fields.py +187 -0
  55. headgate/ratelimit/_inflight.py +92 -0
  56. headgate/ratelimit/_rate.py +103 -0
  57. headgate/ratelimit/_transport.py +106 -0
  58. headgate/retry/__init__.py +40 -0
  59. headgate/retry/_budget.py +73 -0
  60. headgate/retry/_classify.py +135 -0
  61. headgate/retry/_policy.py +254 -0
  62. headgate/retry/_tracking.py +121 -0
  63. headgate/retry/_transport.py +448 -0
  64. headgate/security/__init__.py +36 -0
  65. headgate/security/_addresses.py +120 -0
  66. headgate/security/_credentials.py +124 -0
  67. headgate/security/_downgrade.py +42 -0
  68. headgate/security/_transport.py +119 -0
  69. headgate/streaming/__init__.py +12 -0
  70. headgate/streaming/_stream.py +164 -0
  71. headgate/testing/__init__.py +58 -0
  72. headgate/testing/_breakers.py +33 -0
  73. headgate/testing/_recording.py +120 -0
  74. headgate/testing/_time.py +84 -0
  75. headgate/testing/_transport.py +115 -0
  76. headgate/testing/faults.py +275 -0
  77. headgate-0.2.0.dist-info/METADATA +373 -0
  78. headgate-0.2.0.dist-info/RECORD +80 -0
  79. headgate-0.2.0.dist-info/WHEEL +4 -0
  80. headgate-0.2.0.dist-info/licenses/LICENSE +21 -0
headgate/_url.py ADDED
@@ -0,0 +1,258 @@
1
+ # Copyright (c) 2026 Khaled Elsayed
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """URL handling: path-segment escaping, and redacting credentials from a URL or a message.
5
+
6
+ **Escaping.** Interpolating a caller's value straight into an f-string path lets that value control the URL's
7
+ structure rather than occupy one segment of it::
8
+
9
+ f"/v2/positions/{symbol}" symbol = "../../v2/account"
10
+ -> httpx2 resolves the dot segments
11
+ -> DELETE https://api.example.test/v2/account
12
+
13
+ The same gap is an ordinary correctness bug wherever a legitimate identifier contains a slash: an unescaped ``BTC/USD``
14
+ builds ``/v2/positions/BTC/USD``, a different endpoint. Escaping belongs in the client because the client is the layer
15
+ that builds URLs; every layer above it deals in real, unescaped identifiers (``BTC/USD`` is the symbol), and pushing
16
+ escaping up would corrupt the value for display, comparison and the response models. :func:`render_path` renders a
17
+ t-string path and escapes every interpolation with :func:`escape_segment`. Query parameters need none of this: httpx2
18
+ encodes ``params=`` itself.
19
+
20
+ **Redaction.** :data:`SECRET_QUERY_PARAMS` names the query parameters that carry a credential, and a ``QueryParamAuth``
21
+ adds the names it injects with :func:`register_secret_params`. :func:`sanitize_url` redacts every URL a ``ClientError``
22
+ message names, :func:`redact_secrets` redacts ``name=value`` pairs and URL userinfo in free text such as an httpx2
23
+ exception message, and the HTTP cache strips the same parameters before it stores a request. A URL's whole userinfo is a
24
+ credential, a bare ``user@`` included: a token sent as the username (``https://ghp_…@host``) is how several hosts
25
+ take one.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from string.templatelib import Template
32
+ from typing import TYPE_CHECKING, Final
33
+ from urllib.parse import quote, unquote_plus, urlsplit, urlunsplit
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Iterable
37
+
38
+ __all__ = [
39
+ "SECRET_QUERY_PARAMS",
40
+ "escape_segment",
41
+ "is_secret_param",
42
+ "redact_secrets",
43
+ "register_secret_params",
44
+ "render_path",
45
+ "route_template",
46
+ "sanitize_url",
47
+ ]
48
+
49
+ #: Query parameter names that carry a credential, compared after :func:`_fold` (case-insensitive, ``_`` and ``-``
50
+ #: ignored).
51
+ SECRET_QUERY_PARAMS: Final = frozenset(
52
+ {
53
+ # API keys and generic secrets
54
+ "apikey",
55
+ "key",
56
+ "accesskey",
57
+ "privatekey",
58
+ "secret",
59
+ "clientsecret",
60
+ "auth",
61
+ "password",
62
+ "passwd",
63
+ "pwd",
64
+ # tokens (OAuth 2.0, OpenID Connect, sessions)
65
+ "token",
66
+ "accesstoken",
67
+ "authtoken",
68
+ "refreshtoken",
69
+ "idtoken",
70
+ "clientassertion",
71
+ "jwt",
72
+ "session",
73
+ "sessionid",
74
+ "crumb",
75
+ # signatures, including AWS SigV4 and Google Cloud Storage signed URLs
76
+ "sig",
77
+ "signature",
78
+ "xamzsignature",
79
+ "xamzcredential",
80
+ "xamzsecuritytoken",
81
+ "xgoogsignature",
82
+ "xgoogcredential",
83
+ }
84
+ )
85
+
86
+ #: Names added by :func:`register_secret_params`, folded. Process-global and add-only: over-redacting is safe.
87
+ _registered_secret_params: set[str] = set()
88
+
89
+ #: The placeholder that replaces a credential.
90
+ _REDACTED: Final = "***"
91
+
92
+ #: A ``name=value`` pair in free text. The value stops at ``&``, whitespace or a quote, which is where an URL embedded
93
+ #: in an exception message ends.
94
+ _PAIR: Final = re.compile(r"([A-Za-z][\w-]*)=([^&\s'\"]+)")
95
+
96
+ #: The userinfo of a URL in free text: everything between ``//`` and an ``@`` before the first ``/``, ``?`` or ``#``.
97
+ _USERINFO: Final = re.compile(r"//[^/?#\s@'\"]+@")
98
+
99
+ #: A value that escapes to nothing but dots is a structural dot segment, not data. See :func:`escape_segment`.
100
+ _DOT_SEGMENTS: Final = {".": "%2E", "..": "%2E%2E"}
101
+
102
+
103
+ def _fold(name: str) -> str:
104
+ """Normalize a parameter name for comparison: lower case, with ``_`` and ``-`` removed."""
105
+ return name.lower().replace("_", "").replace("-", "")
106
+
107
+
108
+ def register_secret_params(names: Iterable[str]) -> None:
109
+ """Treat *names* as credential parameters from now on, in every client in the process."""
110
+ _registered_secret_params.update(_fold(n) for n in names)
111
+
112
+
113
+ def is_secret_param(name: str) -> bool:
114
+ """Return whether the query parameter *name* carries a credential."""
115
+ folded = _fold(name)
116
+ return folded in SECRET_QUERY_PARAMS or folded in _registered_secret_params
117
+
118
+
119
+ def redact_secrets(text: str) -> str:
120
+ """Return *text* with every URL userinfo, and the value of every credential ``name=value`` pair, replaced.
121
+
122
+ A userinfo (``//user:pw@`` or ``//token@``) becomes ``//***@``; a pair whose name is a credential keeps its name.
123
+ """
124
+ text = _USERINFO.sub(f"//{_REDACTED}@", text)
125
+ return _PAIR.sub(lambda m: f"{m[1]}={_REDACTED}" if is_secret_param(m[1]) else m[0], text)
126
+
127
+
128
+ def escape_segment(value: object) -> str:
129
+ """Escape *value* as exactly one URL path segment.
130
+
131
+ *value* is stringified first, since call sites pass ``UUID`` and ``int`` ids as well as ``str``. ``safe=""`` is the
132
+ point: :func:`urllib.parse.quote` keeps ``/`` by default. Everything structural (``/``, ``?``, ``#``, whitespace)
133
+ is percent-encoded, while characters legal inside a segment survive, notably the ``.`` in ``BRK.B``.
134
+
135
+ Keeping ``.`` legal means a value made only of dots would reach the URL as a live dot segment, which httpx2
136
+ resolves: ``DELETE /v2/positions/.`` collapses to ``DELETE /v2/positions``, which closes **every** position
137
+ instead of one. So ``.`` and ``..`` map to their percent-encoded forms, which httpx2 does not resolve. The mapping
138
+ applies only when the whole escaped value is dots, so ``BRK.B`` and a literal ``..`` between two holes (a
139
+ ``{start}..{end}`` range) are untouched.
140
+
141
+ :raises ValueError: if *value* is empty. ``t"/v2/positions/{symbol}"`` with ``""`` would render
142
+ ``/v2/positions/``, one trailing slash from the collection endpoint, and a router that strips the slash with a
143
+ 301 or 308 re-sends the request there. No path segment is legitimately empty.
144
+ """
145
+ escaped = quote(str(value), safe="")
146
+ if not escaped:
147
+ msg = "empty URL path segment"
148
+ raise ValueError(msg)
149
+ return _DOT_SEGMENTS.get(escaped, escaped)
150
+
151
+
152
+ def render_path(template: Template) -> str:
153
+ """Render a t-string URL path, escaping every interpolation with :func:`escape_segment` by default.
154
+
155
+ An f-string is one flat ``str`` by the time anything sees it, but a :class:`~string.templatelib.Template` keeps
156
+ its literals and its holes apart, so ``t"/v2/positions/{symbol}"`` yields ``BTC%2FUSD`` with nothing for the
157
+ author to remember.
158
+
159
+ Only two things splice verbatim:
160
+
161
+ * A nested ``Template``, trusted for what it is rather than what it says, so shared prefixes stay unannotated:
162
+ ``render_path(t"{_PREFIX}/accounts")`` with ``_PREFIX = t"/api/v3"`` gives ``"/api/v3/accounts"``. A nested
163
+ template's own holes are still escaped, so trust attaches to structure, never to the values inside it. A
164
+ ``:raw`` spec on a ``Template`` is ignored, so it cannot launder those values.
165
+ * A value formatted with the reserved spec ``raw`` (``t"/x/{fragment:raw}"``), the one escape hatch for a pre-built
166
+ fragment. It is a spec rather than a second function so that every unescaped interpolation is written ``:raw}``
167
+ at its point of use. A spec can itself be interpolated (``t"/x/{v:{spec}}"`` with ``spec = "raw"``), which
168
+ renders unescaped without matching a search for ``:raw}``.
169
+
170
+ Any other ``format_spec`` is applied first and the result escaped, so ``t"/shvol{d:%Y%m%d}.txt"`` reads as its
171
+ f-string counterpart did. Recursion is bounded: ``Template`` is immutable, so a cycle cannot be built.
172
+
173
+ :raises TypeError: if *template* is not a ``Template``. A ``str`` would otherwise iterate as literal characters and
174
+ come back unchanged: an f-string's already-interpolated, unescaped text passing silently through.
175
+ :raises ValueError: if an interpolation carries a conversion (``!r``, ``!s``, ``!a``), which a URL segment almost
176
+ never wants and which dropping silently would change what the equivalent f-string did.
177
+ """
178
+ if not isinstance(template, Template):
179
+ msg = f"render_path() takes a t-string Template, got {type(template).__name__}"
180
+ raise TypeError(msg)
181
+
182
+ out: list[str] = []
183
+ for part in template:
184
+ if isinstance(part, str):
185
+ out.append(part)
186
+ continue
187
+ if part.conversion is not None:
188
+ msg = f"conversion !{part.conversion} is not supported in a URL path: {part.expression!r}"
189
+ raise ValueError(msg)
190
+ if isinstance(part.value, Template):
191
+ out.append(render_path(part.value))
192
+ elif part.format_spec == "raw":
193
+ out.append(str(part.value))
194
+ else:
195
+ value = format(part.value, part.format_spec) if part.format_spec else str(part.value)
196
+ out.append(escape_segment(value))
197
+ return "".join(out)
198
+
199
+
200
+ def route_template(template: Template) -> str:
201
+ """Return the route a t-string URL path names: its literal text with each hole written as ``{expression}``.
202
+
203
+ ``t"series/{series_id}/observations"`` gives ``"series/{series_id}/observations"`` whatever the id, so events and
204
+ traces can group calls by endpoint without the values in them. A nested ``Template`` contributes its own route; a
205
+ format spec, ``:raw`` included, is not shown.
206
+ """
207
+ out: list[str] = []
208
+ for part in template:
209
+ if isinstance(part, str):
210
+ out.append(part)
211
+ elif isinstance(part.value, Template):
212
+ out.append(route_template(part.value))
213
+ else:
214
+ out.append(f"{{{part.expression}}}")
215
+ return "".join(out)
216
+
217
+
218
+ def _redact_query(query: str) -> str:
219
+ """Return *query* with the value of every credential parameter replaced, and every other byte kept as it was.
220
+
221
+ Names are compared decoded, so ``api%5Fkey`` is caught too. The placeholder is written percent-encoded
222
+ (``%2A%2A%2A``), as a query value is.
223
+ """
224
+ placeholder = quote(_REDACTED)
225
+ pairs: list[str] = []
226
+ for pair in query.split("&"):
227
+ name = pair.partition("=")[0]
228
+ pairs.append(f"{name}={placeholder}" if is_secret_param(unquote_plus(name)) else pair)
229
+ return "&".join(pairs)
230
+
231
+
232
+ def _redact_userinfo(netloc: str) -> str:
233
+ """Return *netloc* with its whole userinfo, when it has one, replaced by ``***``."""
234
+ userinfo, _, hostport = netloc.rpartition("@")
235
+ return f"{_REDACTED}@{hostport}" if userinfo else netloc
236
+
237
+
238
+ def sanitize_url(url: str) -> str:
239
+ """Return *url* with its credentials replaced by ``***``; it never raises.
240
+
241
+ That covers secret query parameters, the whole userinfo, and secret ``name=value`` pairs in the fragment (an OAuth
242
+ implicit-flow ``#access_token=``). Everything else in the URL is kept byte for byte, so the URL a message names is
243
+ the one that was sent. A URL too malformed to split (an unclosed IPv6 bracket) is redacted as free text with
244
+ :func:`redact_secrets` instead, since its message still has to be safe to show.
245
+ """
246
+ if not any(marker in url for marker in ("?", "#", "@")):
247
+ return url
248
+ try:
249
+ parts = urlsplit(url)
250
+ except ValueError:
251
+ return redact_secrets(url)
252
+ return urlunsplit(
253
+ parts._replace(
254
+ netloc=_redact_userinfo(parts.netloc),
255
+ query=_redact_query(parts.query),
256
+ fragment=redact_secrets(parts.fragment),
257
+ )
258
+ )
@@ -0,0 +1,30 @@
1
+ # Copyright (c) 2026 Khaled Elsayed
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """The ``User-Agent`` every client sends: the caller's product token, then headgate's, httpx2's and Python's."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ from importlib import metadata
10
+
11
+ import httpx2
12
+
13
+ __all__ = ["DEFAULT_USER_AGENT", "headgate_version", "user_agent"]
14
+
15
+
16
+ def headgate_version() -> str:
17
+ """Return the installed headgate version, or ``"unknown"`` for a copy not installed as a distribution."""
18
+ try:
19
+ return metadata.version("headgate")
20
+ except metadata.PackageNotFoundError:
21
+ return "unknown"
22
+
23
+
24
+ #: What a client without ``HttpOptions(user_agent=...)`` sends.
25
+ DEFAULT_USER_AGENT = f"headgate/{headgate_version()} httpx2/{httpx2.__version__} python/{platform.python_version()}"
26
+
27
+
28
+ def user_agent(product: str | None) -> str:
29
+ """Return the ``User-Agent`` for a client whose own product token is *product* (``None``: headgate's alone)."""
30
+ return DEFAULT_USER_AGENT if product is None else f"{product} {DEFAULT_USER_AGENT}"
headgate/_view.py ADDED
@@ -0,0 +1,105 @@
1
+ # Copyright (c) 2026 Khaled Elsayed
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """The defaults a view made by :meth:`~headgate.AsyncBaseClient.with_options` lays over its client's calls.
5
+
6
+ A view is the same client with different defaults for its calls: extra headers, a retry policy and a deadline (its
7
+ timeouts replace the client's directly). :class:`ViewDefaults` holds them, and the client consults it where it applies
8
+ a call's own options, so the precedence is the call's, then the view's, then the route's, then the policy's.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass, replace
15
+ from enum import Enum
16
+ from typing import TYPE_CHECKING, Final, Literal
17
+
18
+ import httpx2
19
+
20
+ from headgate._options import Timeouts
21
+ from headgate.retry import NO_RETRY
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Mapping
25
+
26
+ from headgate.retry import RetryPolicy
27
+
28
+ __all__ = ["KEEP", "ROOT_DEFAULTS", "Keep", "ViewDefaults", "overlay_timeout"]
29
+
30
+
31
+ class Keep(Enum):
32
+ """The ``with_options`` default that keeps a setting as it is, where ``None`` is itself a value."""
33
+
34
+ KEEP = "keep"
35
+
36
+
37
+ #: Keep the setting the client already has.
38
+ KEEP: Final = Keep.KEEP
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ViewDefaults:
43
+ """What a view adds to each of its calls: ``headers`` under the call's own, and a ``deadline`` and ``retry``.
44
+
45
+ ``headers`` is a tuple of pairs so the value stays immutable; ``deadline`` is ``KEEP`` when the view sets none.
46
+ """
47
+
48
+ headers: tuple[tuple[str, str], ...] = ()
49
+ deadline: float | Literal[Keep.KEEP] | None = KEEP
50
+ retry: RetryPolicy | None = None
51
+
52
+ def headers_for(self, call: Mapping[str, str] | None) -> Mapping[str, str] | httpx2.Headers | None:
53
+ """Return the headers a call sends beyond the client's: the view's, then *call*'s, which win a clash."""
54
+ if not self.headers:
55
+ return call
56
+ merged = httpx2.Headers(list(self.headers))
57
+ if call is not None:
58
+ merged.update(call)
59
+ return merged
60
+
61
+ def layered(
62
+ self,
63
+ *,
64
+ headers: Mapping[str, str] | None,
65
+ deadline: float | Literal[Keep.KEEP] | None,
66
+ retry: bool | RetryPolicy | None,
67
+ ) -> ViewDefaults:
68
+ """Return these defaults with a further view's laid over them; ``None`` / ``KEEP`` keeps a setting.
69
+
70
+ :raises ValueError: for a deadline that is not positive and finite.
71
+ """
72
+ changed = self
73
+ if headers is not None:
74
+ merged = httpx2.Headers(list(self.headers))
75
+ merged.update(headers)
76
+ changed = replace(changed, headers=tuple(merged.items()))
77
+ if deadline is not KEEP:
78
+ if deadline is not None and not (deadline > 0 and math.isfinite(deadline)):
79
+ msg = f"deadline must be > 0 and finite, or None (got {deadline})"
80
+ raise ValueError(msg)
81
+ changed = replace(changed, deadline=deadline)
82
+ if retry is not None:
83
+ changed = replace(changed, retry=_retry_override(retry=retry))
84
+ return changed
85
+
86
+
87
+ def _retry_override(*, retry: bool | RetryPolicy) -> RetryPolicy | None:
88
+ """Return the retry policy a view's ``retry=`` installs: ``True`` is the client's own (no override)."""
89
+ if retry is True:
90
+ return None
91
+ if retry is False:
92
+ return NO_RETRY
93
+ return retry
94
+
95
+
96
+ def overlay_timeout(base: Timeouts | float, view: Timeouts | float) -> Timeouts:
97
+ """Return *base* with a view's timeout applied: a ``Timeouts`` replaces all four, a number the read timeout."""
98
+ if isinstance(view, Timeouts):
99
+ return view
100
+ current = base if isinstance(base, Timeouts) else Timeouts(read=base)
101
+ return replace(current, read=view)
102
+
103
+
104
+ #: The defaults of a client that is not a view: nothing added.
105
+ ROOT_DEFAULTS: Final = ViewDefaults()
@@ -0,0 +1,24 @@
1
+ # Copyright (c) 2026 Khaled Elsayed
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """The circuit breaker: :class:`BreakerPolicy` configures it, :class:`BreakerRegistry` shares it between clients.
5
+
6
+ Self-contained: it imports only ``httpx2``, the standard library and :mod:`headgate._core`.
7
+ """
8
+
9
+ from headgate._core import BreakerState
10
+ from headgate.breaker._breaker import BreakerOpenError, CircuitBreaker, Transition, Verdict
11
+ from headgate.breaker._policy import BreakerPolicy
12
+ from headgate.breaker._registry import DEFAULT_MAX_HOSTS, DEFAULT_REGISTRY, BreakerRegistry
13
+
14
+ __all__ = [
15
+ "DEFAULT_MAX_HOSTS",
16
+ "DEFAULT_REGISTRY",
17
+ "BreakerOpenError",
18
+ "BreakerPolicy",
19
+ "BreakerRegistry",
20
+ "BreakerState",
21
+ "CircuitBreaker",
22
+ "Transition",
23
+ "Verdict",
24
+ ]