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 +314 -0
- fizzpy/_common.py +170 -0
- fizzpy/_core.cp313-win_amd64.pyd +0 -0
- fizzpy/_core.pyi +116 -0
- fizzpy/_http.py +463 -0
- fizzpy/_transport.py +70 -0
- fizzpy/aio.py +235 -0
- fizzpy-0.4.0.dist-info/DELVEWHEEL +2 -0
- fizzpy-0.4.0.dist-info/METADATA +163 -0
- fizzpy-0.4.0.dist-info/RECORD +17 -0
- fizzpy-0.4.0.dist-info/WHEEL +5 -0
- fizzpy-0.4.0.dist-info/licenses/LICENSE +21 -0
- fizzpy.libs/gflags-a6801de0fc7d34bda117ae7e33a580c2.dll +0 -0
- fizzpy.libs/glog-15c5eaef571d333810228a51d353e600.dll +0 -0
- fizzpy.libs/libcrypto-3-8e34ac204b62a7ad839b7388b63bba50.dll +0 -0
- fizzpy.libs/libsodium-f1b56eb55a86edee37a2614c8bd38272.dll +0 -0
- fizzpy.libs/msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll +0 -0
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: ...
|