fizzpy 0.4.0__cp313-cp313-win_amd64.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.
fizzpy/__init__.py ADDED
@@ -0,0 +1,314 @@
1
+ """fizzpy — a low-level TLS 1.3 client toolkit built on Facebook's Fizz.
2
+
3
+ The synchronous facade lives here; the asyncio facade is :mod:`fizzpy.aio`.
4
+ Both drive the same C++ core, which runs every connection on a background
5
+ EventBase thread.
6
+
7
+ import fizzpy
8
+
9
+ r = fizzpy.get("https://www.cloudflare.com")
10
+ print(r.status_code, r.headers["content-type"])
11
+ print(r.tls) # negotiated TLS 1.3 parameters
12
+
13
+ Certificate chains are verified against the system trust store and the
14
+ hostname is checked against the certificate's SAN by default.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+
20
+ # start delvewheel patch
21
+ def _delvewheel_patch_1_13_0():
22
+ import os
23
+ if os.path.isdir(libs_dir := os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'fizzpy.libs'))):
24
+ os.add_dll_directory(libs_dir)
25
+
26
+
27
+ _delvewheel_patch_1_13_0()
28
+ del _delvewheel_patch_1_13_0
29
+ # end delvewheel patch
30
+
31
+ import threading
32
+ from collections.abc import Mapping, Sequence
33
+ from typing import Any
34
+
35
+ from . import _core
36
+ from ._common import (
37
+ DEFAULT_ALPN,
38
+ DEFAULT_GROUPS,
39
+ DEFAULT_MAX_REDIRECTS,
40
+ DEFAULT_TIMEOUT_MS,
41
+ READ_DONE,
42
+ Extension,
43
+ Target,
44
+ TooManyRedirects,
45
+ default_ca_file,
46
+ next_redirect,
47
+ normalize_extensions,
48
+ parse_url,
49
+ strip_body_headers,
50
+ )
51
+ from ._core import NamedGroup
52
+ from ._http import Headers, Response, ResponseParser, build_request
53
+ from ._transport import run_sync
54
+
55
+ __all__ = [
56
+ "Client",
57
+ "Extension",
58
+ "Headers",
59
+ "NamedGroup",
60
+ "Response",
61
+ "TlsParameters",
62
+ "TooManyRedirects",
63
+ "delete",
64
+ "get",
65
+ "head",
66
+ "post",
67
+ "put",
68
+ "request",
69
+ ]
70
+
71
+ # Negotiated parameters are returned from the core as a plain dict; expose the
72
+ # key set as documentation.
73
+ TlsParameters = dict
74
+
75
+
76
+ class _Stale(Exception):
77
+ """Internal: a pooled connection was dead; retry once on a fresh one."""
78
+
79
+
80
+ class Client:
81
+ """A synchronous TLS HTTP client.
82
+
83
+ Idle connections are kept in a per-host pool and reused (HTTP/1.1
84
+ keep-alive). A pooled connection found dead on reuse is transparently
85
+ replaced for idempotent retries. Call :meth:`close` (or use the client as a
86
+ context manager) to close pooled connections.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ *,
92
+ verify: bool = True,
93
+ cafile: str | None = None,
94
+ timeout: float = DEFAULT_TIMEOUT_MS / 1000,
95
+ alpn: list[str] | None = None,
96
+ groups: list[_core.NamedGroup] | None = None,
97
+ extensions: Sequence[tuple[int, bytes]] | None = None,
98
+ follow_redirects: bool = True,
99
+ max_redirects: int = DEFAULT_MAX_REDIRECTS,
100
+ ) -> None:
101
+ self._verify = verify
102
+ self._cafile = cafile or default_ca_file()
103
+ self._timeout_ms = int(timeout * 1000)
104
+ self._alpn = list(alpn) if alpn is not None else list(DEFAULT_ALPN)
105
+ self._groups = list(groups) if groups is not None else list(DEFAULT_GROUPS)
106
+ self._extensions = normalize_extensions(extensions)
107
+ self._follow_redirects = follow_redirects
108
+ self._max_redirects = max_redirects
109
+ self._pool: dict[tuple[str, int], list[_core.TlsConnection]] = {}
110
+ self._lock = threading.Lock()
111
+
112
+ def request(
113
+ self,
114
+ method: str,
115
+ url: str,
116
+ *,
117
+ headers: Mapping[str, str] | None = None,
118
+ body: bytes | None = None,
119
+ ) -> Response:
120
+ method = method.upper()
121
+ seen = 0
122
+ while True:
123
+ response = self._perform(method, url, headers=headers, body=body)
124
+ response.url = url
125
+ nxt = (
126
+ next_redirect(
127
+ method, url, response.status_code, response.headers.get("Location")
128
+ )
129
+ if self._follow_redirects
130
+ else None
131
+ )
132
+ if nxt is None:
133
+ return response
134
+ if seen >= self._max_redirects:
135
+ raise TooManyRedirects(
136
+ f"exceeded {self._max_redirects} redirects (last: {url})"
137
+ )
138
+ new_method, url = nxt
139
+ if new_method != method:
140
+ method, body, headers = new_method, None, strip_body_headers(headers)
141
+ seen += 1
142
+
143
+ def _perform(
144
+ self,
145
+ method: str,
146
+ url: str,
147
+ *,
148
+ headers: Mapping[str, str] | None = None,
149
+ body: bytes | None = None,
150
+ ) -> Response:
151
+ target = parse_url(url)
152
+ key = (target.host, target.port)
153
+ raw = build_request(
154
+ method, target.path, target.host, dict(headers) if headers else None, body
155
+ )
156
+
157
+ pooled = self._take(key)
158
+ if pooled is not None:
159
+ try:
160
+ return self._exchange(pooled, key, raw, reused=True)
161
+ except _Stale:
162
+ pooled.close() # dead idle connection; fall through to a fresh one
163
+
164
+ return self._exchange(self._connect(target), key, raw, reused=False)
165
+
166
+ def _connect(self, target: Target) -> _core.TlsConnection:
167
+ conn = _core.TlsConnection()
168
+ run_sync(
169
+ lambda resolve, reject: conn.connect(
170
+ target.host,
171
+ target.port,
172
+ target.host,
173
+ self._alpn,
174
+ self._groups,
175
+ self._verify,
176
+ self._cafile,
177
+ self._timeout_ms,
178
+ self._extensions,
179
+ resolve,
180
+ reject,
181
+ )
182
+ )
183
+ return conn
184
+
185
+ def _exchange(
186
+ self,
187
+ conn: _core.TlsConnection,
188
+ key: tuple[str, int],
189
+ raw: bytes,
190
+ *,
191
+ reused: bool,
192
+ ) -> Response:
193
+ try:
194
+ run_sync(lambda resolve, reject: conn.write(raw, resolve, reject))
195
+ except Exception as exc:
196
+ # A reused connection that fails before the request lands was closed
197
+ # by the server while idle — safe to retry on a fresh one.
198
+ if reused:
199
+ raise _Stale from exc
200
+ raise
201
+
202
+ parser = ResponseParser()
203
+ received = False
204
+ while not parser.is_complete():
205
+ try:
206
+ chunk = run_sync(lambda resolve, reject: conn.read(resolve, reject))
207
+ except Exception as exc:
208
+ if reused and not received:
209
+ raise _Stale from exc
210
+ raise
211
+ if chunk == READ_DONE:
212
+ if reused and not received:
213
+ raise _Stale
214
+ parser.feed_eof()
215
+ break
216
+ received = True
217
+ parser.feed(chunk)
218
+
219
+ response = parser.response
220
+ if response is None:
221
+ if reused:
222
+ raise _Stale
223
+ raise ConnectionError("connection closed before a full response")
224
+ response.tls = conn.negotiated()
225
+
226
+ if self._reusable(parser, response):
227
+ self._give(key, conn)
228
+ else:
229
+ conn.close()
230
+ return response
231
+
232
+ @staticmethod
233
+ def _reusable(parser: ResponseParser, response: Response) -> bool:
234
+ if parser.used_close_framing or response.http_version != "HTTP/1.1":
235
+ return False
236
+ return "close" not in (response.headers.get("Connection") or "").lower()
237
+
238
+ def _take(self, key: tuple[str, int]) -> _core.TlsConnection | None:
239
+ with self._lock:
240
+ conns = self._pool.get(key)
241
+ return conns.pop() if conns else None
242
+
243
+ def _give(self, key: tuple[str, int], conn: _core.TlsConnection) -> None:
244
+ with self._lock:
245
+ self._pool.setdefault(key, []).append(conn)
246
+
247
+ def close(self) -> None:
248
+ """Close all pooled connections."""
249
+ with self._lock:
250
+ pooled = [c for conns in self._pool.values() for c in conns]
251
+ self._pool.clear()
252
+ for conn in pooled:
253
+ conn.close()
254
+
255
+ def get(self, url: str, **kwargs: Any) -> Response:
256
+ return self.request("GET", url, **kwargs)
257
+
258
+ def post(self, url: str, **kwargs: Any) -> Response:
259
+ return self.request("POST", url, **kwargs)
260
+
261
+ def head(self, url: str, **kwargs: Any) -> Response:
262
+ return self.request("HEAD", url, **kwargs)
263
+
264
+ def put(self, url: str, **kwargs: Any) -> Response:
265
+ return self.request("PUT", url, **kwargs)
266
+
267
+ def delete(self, url: str, **kwargs: Any) -> Response:
268
+ return self.request("DELETE", url, **kwargs)
269
+
270
+ def __enter__(self) -> Client:
271
+ return self
272
+
273
+ def __exit__(self, *exc: object) -> None:
274
+ self.close()
275
+
276
+
277
+ _CLIENT_KWARGS = frozenset(
278
+ {
279
+ "verify",
280
+ "cafile",
281
+ "timeout",
282
+ "alpn",
283
+ "groups",
284
+ "extensions",
285
+ "follow_redirects",
286
+ "max_redirects",
287
+ }
288
+ )
289
+
290
+
291
+ def request(method: str, url: str, **kwargs: Any) -> Response:
292
+ """One-shot request with a throwaway :class:`Client`."""
293
+ client_kwargs = {k: kwargs.pop(k) for k in list(kwargs) if k in _CLIENT_KWARGS}
294
+ return Client(**client_kwargs).request(method, url, **kwargs)
295
+
296
+
297
+ def get(url: str, **kwargs: Any) -> Response:
298
+ return request("GET", url, **kwargs)
299
+
300
+
301
+ def post(url: str, **kwargs: Any) -> Response:
302
+ return request("POST", url, **kwargs)
303
+
304
+
305
+ def head(url: str, **kwargs: Any) -> Response:
306
+ return request("HEAD", url, **kwargs)
307
+
308
+
309
+ def put(url: str, **kwargs: Any) -> Response:
310
+ return request("PUT", url, **kwargs)
311
+
312
+
313
+ def delete(url: str, **kwargs: Any) -> Response:
314
+ return request("DELETE", url, **kwargs)
fizzpy/_common.py ADDED
@@ -0,0 +1,170 @@
1
+ """Shared request plumbing used by both the sync and async facades."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import NamedTuple
7
+ from urllib.parse import urljoin, urlsplit
8
+
9
+ from . import _core # pyright: ignore[reportPrivateUsage] # our own extension module
10
+
11
+ DEFAULT_ALPN = ["http/1.1"]
12
+ DEFAULT_TIMEOUT_MS = 30_000
13
+ DEFAULT_MAX_REDIRECTS = 10
14
+ READ_DONE = b""
15
+
16
+
17
+ def default_ca_file() -> str:
18
+ """Path to the CA bundle used when a caller doesn't supply one.
19
+
20
+ The extension bundles its own OpenSSL, whose compiled-in trust-store path
21
+ doesn't exist on most hosts (and not at all in minimal images), so the
22
+ OpenSSL default verifies nothing out of the box. certifi gives every
23
+ install the Mozilla root set with no system setup. If certifi is somehow
24
+ unavailable we return "" and fall back to OpenSSL's default store.
25
+ """
26
+ try:
27
+ import certifi
28
+
29
+ return certifi.where()
30
+ except Exception:
31
+ return ""
32
+
33
+
34
+ # Status codes that carry a Location and request a follow-up request.
35
+ REDIRECT_CODES = frozenset({301, 302, 303, 307, 308})
36
+
37
+
38
+ class TooManyRedirects(Exception):
39
+ """Raised when a request exceeds the client's redirect limit."""
40
+
41
+
42
+ # Offer the standardized post-quantum hybrid group first, with classical x25519
43
+ # as fallback — mirroring how Chrome/Firefox send both key shares by default.
44
+ # A server without ML-KEM support simply selects x25519 from our shares.
45
+ DEFAULT_GROUPS = [_core.NamedGroup.x25519_mlkem768, _core.NamedGroup.x25519]
46
+
47
+
48
+ class Extension(NamedTuple):
49
+ """A custom TLS ClientHello extension: a type code and its opaque payload."""
50
+
51
+ type: int
52
+ data: bytes
53
+
54
+
55
+ # Extension types Fizz assembles into the ClientHello itself (from
56
+ # fizz/record/Types.h and the ClientHello build in ClientProtocol.cpp). Injecting
57
+ # any of these would duplicate or contradict Fizz's own, so they're refused.
58
+ FIZZ_MANAGED_EXTENSIONS = frozenset(
59
+ {
60
+ 0, # server_name
61
+ 10, # supported_groups
62
+ 13, # signature_algorithms
63
+ 16, # application_layer_protocol_negotiation
64
+ 41, # pre_shared_key
65
+ 42, # early_data
66
+ 43, # supported_versions
67
+ 44, # cookie
68
+ 45, # psk_key_exchange_modes
69
+ 47, # certificate_authorities
70
+ 50, # signature_algorithms_cert
71
+ 51, # key_share
72
+ 0xFE0D, # encrypted_client_hello
73
+ }
74
+ )
75
+
76
+
77
+ def normalize_extensions(
78
+ extensions: Sequence[tuple[int, bytes]] | None,
79
+ ) -> list[tuple[int, bytes]]:
80
+ """Validate and coerce caller extensions to ``list[(int, bytes)]``.
81
+
82
+ Accepts any iterable of ``(type, data)`` pairs (e.g. :class:`Extension`).
83
+ Raises ``ValueError`` for an out-of-range type, oversized data, a Fizz-managed
84
+ type, or a duplicate type — naming the offender.
85
+ """
86
+ if not extensions:
87
+ return []
88
+ out: list[tuple[int, bytes]] = []
89
+ seen: set[int] = set()
90
+ for item in extensions:
91
+ etype, data = item
92
+ etype = int(etype)
93
+ if not 0 <= etype <= 0xFFFF:
94
+ raise ValueError(f"extension type {etype} out of range 0..65535")
95
+ data = bytes(data)
96
+ if len(data) > 0xFFFF:
97
+ raise ValueError(
98
+ f"extension {etype} data is {len(data)} bytes; max is 65535"
99
+ )
100
+ if etype in FIZZ_MANAGED_EXTENSIONS:
101
+ raise ValueError(
102
+ f"extension type {etype} is managed by Fizz and cannot be injected"
103
+ )
104
+ if etype in seen:
105
+ raise ValueError(f"duplicate extension type {etype}")
106
+ seen.add(etype)
107
+ out.append((etype, data))
108
+ return out
109
+
110
+
111
+ class Target(NamedTuple):
112
+ """A parsed request target: where to connect and what to ask for."""
113
+
114
+ host: str
115
+ port: int
116
+ path: str
117
+
118
+
119
+ def next_redirect(
120
+ method: str, url: str, status_code: int, location: str | None
121
+ ) -> tuple[str, str] | None:
122
+ """Resolve a redirect into the next ``(method, absolute_url)`` to request.
123
+
124
+ Returns ``None`` when the response is not a redirect or carries no
125
+ ``Location``. Method rewriting follows what browsers and ``requests`` do:
126
+ 303 forces a GET; 301/302 downgrade POST to GET; 307/308 preserve the
127
+ original method (and the caller's body).
128
+ """
129
+ if status_code not in REDIRECT_CODES or not location:
130
+ return None
131
+
132
+ target = urljoin(url, location)
133
+ method = method.upper()
134
+ if (status_code == 303 and method != "HEAD") or (
135
+ status_code in (301, 302) and method == "POST"
136
+ ):
137
+ method = "GET"
138
+ return method, target
139
+
140
+
141
+ def strip_body_headers(
142
+ headers: Mapping[str, str] | None,
143
+ ) -> dict[str, str] | None:
144
+ """Drop body-specific headers when a redirect downgrades a request to GET."""
145
+ if not headers:
146
+ return None
147
+ return {
148
+ k: v
149
+ for k, v in headers.items()
150
+ if k.lower() not in ("content-length", "content-type", "transfer-encoding")
151
+ }
152
+
153
+
154
+ def parse_url(url: str) -> Target:
155
+ """Split an ``https://`` URL into connection target + request path.
156
+
157
+ Only TLS is supported — this is a TLS client. ``http://`` is rejected so a
158
+ plaintext request can never silently happen.
159
+ """
160
+ parts = urlsplit(url)
161
+ if parts.scheme != "https":
162
+ raise ValueError(f"fizzpy only supports https:// URLs, got {parts.scheme!r}")
163
+ if not parts.hostname:
164
+ raise ValueError(f"no host in URL: {url!r}")
165
+
166
+ path = parts.path or "/"
167
+ if parts.query:
168
+ path = f"{path}?{parts.query}"
169
+
170
+ return Target(host=parts.hostname, port=parts.port or 443, path=path)
Binary file
fizzpy/_core.pyi ADDED
@@ -0,0 +1,116 @@
1
+ """Type stub for the compiled ``fizzpy._core`` extension.
2
+
3
+ Hand-authored from the pybind11 bindings in ``_core.cpp``. The real extension is
4
+ built by scikit-build-core; a stubgen-generated stub needs that build, so this
5
+ stub is maintained by hand and must track the bindings. It exists so the pure
6
+ Python facades type-check strictly against the native boundary.
7
+ """
8
+
9
+ from collections.abc import Callable
10
+ from enum import IntEnum
11
+
12
+ class ProtocolVersion(IntEnum):
13
+ tls_1_0 = ...
14
+ tls_1_1 = ...
15
+ tls_1_2 = ...
16
+ tls_1_3 = ...
17
+ tls_1_3_23 = ...
18
+ tls_1_3_23_fb = ...
19
+ tls_1_3_26 = ...
20
+ tls_1_3_26_fb = ...
21
+ tls_1_3_28 = ...
22
+
23
+ class NamedGroup(IntEnum):
24
+ secp256r1 = ...
25
+ secp384r1 = ...
26
+ secp521r1 = ...
27
+ x25519 = ...
28
+ x448 = ...
29
+ mlkem512 = ...
30
+ mlkem768 = ...
31
+ mlkem1024 = ...
32
+ secp256r1_mlkem768 = ...
33
+ x25519_mlkem768 = ...
34
+
35
+ class PskKeyExchangeMode(IntEnum):
36
+ psk_ke = ...
37
+ psk_dhe_ke = ...
38
+
39
+ class CipherSuite(IntEnum):
40
+ TLS_AES_128_GCM_SHA256 = ...
41
+ TLS_AES_256_GCM_SHA384 = ...
42
+ TLS_CHACHA20_POLY1305_SHA256 = ...
43
+
44
+ class SignatureScheme(IntEnum):
45
+ ecdsa_secp256r1_sha256 = ...
46
+ ecdsa_secp384r1_sha384 = ...
47
+ ecdsa_secp521r1_sha512 = ...
48
+ rsa_pss_sha256 = ...
49
+ rsa_pss_sha384 = ...
50
+ rsa_pss_sha512 = ...
51
+ ed25519 = ...
52
+ ed448 = ...
53
+
54
+ class SendKeyShare(IntEnum):
55
+ Always = ...
56
+ WhenNecessary = ...
57
+
58
+ class FizzClientContext:
59
+ def __init__(self) -> None: ...
60
+ def setSupportedVersions(self, versions: list[ProtocolVersion]) -> None: ...
61
+ def getSupportedVersions(self) -> list[ProtocolVersion]: ...
62
+ def setSupportedCiphers(self, ciphers: list[CipherSuite]) -> None: ...
63
+ def getSupportedCiphers(self) -> list[CipherSuite]: ...
64
+ def setSupportedSigSchemes(self, schemes: list[SignatureScheme]) -> None: ...
65
+ def getSupportedSigSchemes(self) -> list[SignatureScheme]: ...
66
+ def setSupportedGroups(self, groups: list[NamedGroup]) -> None: ...
67
+ def getSupportedGroups(self) -> list[NamedGroup]: ...
68
+ def setSupportedPskModes(self, modes: list[PskKeyExchangeMode]) -> None: ...
69
+ def getSupportedPskModes(self) -> list[PskKeyExchangeMode]: ...
70
+ def setSupportedAlpns(self, alpns: list[str]) -> None: ...
71
+ def getSupportedAlpns(self) -> list[str]: ...
72
+ def setSendEarlyData(self, send: bool) -> None: ...
73
+ def getSendEarlyData(self) -> bool: ...
74
+ def setSendKeyShare(self, mode: SendKeyShare) -> None: ...
75
+ def getSendKeyShare(self) -> SendKeyShare: ...
76
+
77
+ # Negotiated TLS parameters returned by ``TlsConnection.negotiated``. Keys:
78
+ # version, cipher, group, alpn, sni, peer_cert (str) and group_code (the numeric
79
+ # NamedGroup codepoint, int). Typed with an object value to stay a plain dict.
80
+ NegotiatedParams = dict[str, object]
81
+
82
+ class TlsConnection:
83
+ """A single TLS 1.3 connection driven on the shared EventBase thread.
84
+
85
+ Every async method takes a ``(resolve, reject)`` callback pair the core
86
+ invokes (holding the GIL) from its background thread when the op completes.
87
+ """
88
+
89
+ def __init__(self) -> None: ...
90
+ def connect(
91
+ self,
92
+ host: str,
93
+ port: int,
94
+ sni: str,
95
+ alpns: list[str],
96
+ groups: list[NamedGroup],
97
+ verify: bool,
98
+ ca_file: str,
99
+ timeout_ms: int,
100
+ extensions: list[tuple[int, bytes]],
101
+ resolve: Callable[[None], None],
102
+ reject: Callable[[BaseException], None],
103
+ ) -> None: ...
104
+ def write(
105
+ self,
106
+ data: bytes,
107
+ resolve: Callable[[None], None],
108
+ reject: Callable[[BaseException], None],
109
+ ) -> None: ...
110
+ def read(
111
+ self,
112
+ resolve: Callable[[bytes], None],
113
+ reject: Callable[[BaseException], None],
114
+ ) -> None: ...
115
+ def negotiated(self) -> NegotiatedParams: ...
116
+ def close(self) -> None: ...