punkreq 0.2.3__tar.gz → 0.3.0__tar.gz
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.
- {punkreq-0.2.3 → punkreq-0.3.0}/PKG-INFO +3 -4
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_connect.py +13 -7
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_pool.py +51 -22
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_proxies.py +6 -2
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_transport.py +30 -4
- punkreq-0.3.0/punkreq/_version.py +1 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/pyproject.toml +3 -4
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/fakes.py +1 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_connect.py +2 -2
- punkreq-0.2.3/punkreq/_version.py +0 -1
- {punkreq-0.2.3 → punkreq-0.3.0}/.gitignore +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/LICENSE +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/README.md +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/__init__.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_auth.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_client.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_config.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_content.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_cookies.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_decoders.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_exceptions.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_headers.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_models.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_multipart.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_redirects.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_urls.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/asyncio.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/py.typed +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/tonio.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/__init__.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/conftest.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/e2e.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/servers.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/servers_tonio.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_client.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_config.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_content.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_cookies.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_decoders.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_e2e_asyncio.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_e2e_tonio.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_exceptions.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_headers.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_models.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_multipart.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_pool.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_redirects.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_transport.py +0 -0
- {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_urls.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: punkreq
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: An async HTTP client built on httpunk
|
|
5
5
|
Project-URL: Homepage, https://github.com/gi0baro/punkreq
|
|
6
6
|
Project-URL: Funding, https://github.com/sponsors/gi0baro
|
|
@@ -15,7 +15,6 @@ Classifier: Intended Audience :: Developers
|
|
|
15
15
|
Classifier: License :: OSI Approved :: BSD License
|
|
16
16
|
Classifier: Operating System :: OS Independent
|
|
17
17
|
Classifier: Programming Language :: Python :: 3
|
|
18
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
@@ -23,8 +22,8 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
23
22
|
Classifier: Programming Language :: Python :: 3.15
|
|
24
23
|
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
25
24
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
26
|
-
Requires-Python: >=3.
|
|
27
|
-
Requires-Dist: httpunk~=0.
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Requires-Dist: httpunk~=0.5.0
|
|
28
27
|
Provides-Extra: brotli
|
|
29
28
|
Requires-Dist: brotli~=1.2.0; extra == 'brotli'
|
|
30
29
|
Provides-Extra: tonio
|
|
@@ -4,6 +4,7 @@ import os
|
|
|
4
4
|
import ssl
|
|
5
5
|
import typing
|
|
6
6
|
|
|
7
|
+
from httpunk.exceptions import HTTPunkError
|
|
7
8
|
from httpunk.h1.client import H1Connection
|
|
8
9
|
from httpunk.h2.client import H2Connection
|
|
9
10
|
|
|
@@ -83,17 +84,19 @@ def create_ssl_context(verify: VerifyTypes = True, cert: CertTypes | None = None
|
|
|
83
84
|
|
|
84
85
|
|
|
85
86
|
def connect_errors(backend: typing.Any) -> tuple[type[BaseException], ...]:
|
|
86
|
-
"""What a dial
|
|
87
|
-
errors,
|
|
87
|
+
"""What a dial, a TLS handshake or the HTTP handshake fails with on
|
|
88
|
+
`backend`: the OS and ssl errors, the backend's own transport failure, and
|
|
89
|
+
httpunk's errors (the peer hanging up or misbehaving while the connection
|
|
90
|
+
is entered, or during a proxy's CONNECT). tonio's `ResourceBroken`
|
|
88
91
|
is a plain `Exception`, not an `OSError`, and its TLS layer raises it in
|
|
89
92
|
place of the ssl error, so `(OSError, ssl.SSLError)` alone lets a failed
|
|
90
93
|
handshake on tonio escape as an unmapped exception."""
|
|
91
|
-
return (OSError, ssl.SSLError, *backend.broken_transport_errors)
|
|
94
|
+
return (OSError, ssl.SSLError, HTTPunkError, *backend.broken_transport_errors)
|
|
92
95
|
|
|
93
96
|
|
|
94
97
|
class Connector:
|
|
95
|
-
"""The default connector: dial `origin` and return the protocol-matching
|
|
96
|
-
|
|
98
|
+
"""The default connector: dial `origin` and return the protocol-matching
|
|
99
|
+
httpunk connection, entered."""
|
|
97
100
|
|
|
98
101
|
def __init__(
|
|
99
102
|
self,
|
|
@@ -126,8 +129,11 @@ class Connector:
|
|
|
126
129
|
async def __call__(self, origin: Origin) -> H1Connection | H2Connection:
|
|
127
130
|
try:
|
|
128
131
|
if origin.scheme == "https":
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
conn = await self._connect_tls(origin)
|
|
133
|
+
else:
|
|
134
|
+
conn = await self._connect_tcp(origin)
|
|
135
|
+
await conn.__aenter__()
|
|
136
|
+
return conn
|
|
131
137
|
except connect_errors(self._backend) as exc:
|
|
132
138
|
raise ConnectError(f"Failed to connect to {origin}: {exc}")
|
|
133
139
|
|
|
@@ -11,14 +11,14 @@ Concept mapping (hyper-util `client/pool/*.rs` ≡ `httpunk.util.pool` ≡ here)
|
|
|
11
11
|
≡ `_HostState.shared` + `.dialing` + `.mode`. Waiters re-attempt internally
|
|
12
12
|
instead of surfacing `Canceled` — the same retry-by-calling-again
|
|
13
13
|
semantics. A closed shared conn is ditched and redialed at checkout
|
|
14
|
-
(`Singled::poll_ready` reset
|
|
14
|
+
(`Singled::poll_ready` reset, as httpunk's `Singleton` does).
|
|
15
15
|
* `Cache` (idle set, drop-returns-to-cache) ≡ `_HostState.idle` + `release()`.
|
|
16
16
|
The park check (`discard or closed or busy`) matches httpunk's `_Lease`:
|
|
17
17
|
`closed` mirrors `Cached::poll_ready`-sets-`is_closed` + `Drop` skipping
|
|
18
|
-
the put; `busy` is the runtime-forced extra
|
|
19
|
-
failed exchange instead of re-parking is httpunk's
|
|
20
|
-
httpunk's `Cache
|
|
21
|
-
|
|
18
|
+
the put; `busy` is the runtime-forced extra httpunk also adds; closing on
|
|
19
|
+
a failed exchange instead of re-parking is httpunk's choice too. Like
|
|
20
|
+
httpunk's `Cache`, there is no checkout-vs-connect race: an empty idle
|
|
21
|
+
set dials directly.
|
|
22
22
|
|
|
23
23
|
Policy layered on top (owner-owned, per the modern pool's design — its only
|
|
24
24
|
culling surface is `Cache::retain(predicate)`, everything else is the
|
|
@@ -133,7 +133,11 @@ class ConnectionPool:
|
|
|
133
133
|
|
|
134
134
|
if action == "dial":
|
|
135
135
|
conn = await self._dial(origin, connect_timeout)
|
|
136
|
-
|
|
136
|
+
installed = self._install(origin, conn)
|
|
137
|
+
if installed is None:
|
|
138
|
+
await self._close_conn(conn)
|
|
139
|
+
raise RuntimeError("The connection pool is closed.")
|
|
140
|
+
return installed
|
|
137
141
|
|
|
138
142
|
# action == "wait": either a coalesced dial or pool capacity
|
|
139
143
|
await self._wait(value, deadline)
|
|
@@ -181,12 +185,19 @@ class ConnectionPool:
|
|
|
181
185
|
with self._lock:
|
|
182
186
|
self._closed = True
|
|
183
187
|
conns = []
|
|
188
|
+
dialing = []
|
|
184
189
|
for host in self._hosts.values():
|
|
185
190
|
if host.shared is not None:
|
|
186
191
|
conns.append(host.shared)
|
|
187
192
|
conns.extend(conn for conn, _ in host.idle)
|
|
193
|
+
if host.dialing is not None:
|
|
194
|
+
dialing.append(host.dialing)
|
|
188
195
|
self._hosts.clear()
|
|
189
196
|
self._count = 0
|
|
197
|
+
# acquirers coalesced on a dial still in flight: its entry is gone, so
|
|
198
|
+
# nothing else would ever set their event
|
|
199
|
+
for event in dialing:
|
|
200
|
+
event.set()
|
|
190
201
|
for conn in conns:
|
|
191
202
|
await self._close_conn(conn)
|
|
192
203
|
self._wake_waiters()
|
|
@@ -250,24 +261,38 @@ class ConnectionPool:
|
|
|
250
261
|
return "wait", event, stale
|
|
251
262
|
|
|
252
263
|
async def _dial(self, origin: Origin, connect_timeout: float | None) -> typing.Any:
|
|
264
|
+
if connect_timeout is None:
|
|
265
|
+
try:
|
|
266
|
+
return await self._connector(origin)
|
|
267
|
+
except BaseException:
|
|
268
|
+
self._abandon_dial(origin)
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
abandoned, dialed, lock = False, [], threading.Lock()
|
|
272
|
+
|
|
273
|
+
async def dial():
|
|
274
|
+
conn = await self._connector(origin)
|
|
275
|
+
with lock:
|
|
276
|
+
if not abandoned:
|
|
277
|
+
dialed.append(conn)
|
|
278
|
+
return
|
|
279
|
+
# the caller is gone: nobody takes it
|
|
280
|
+
await self._close_conn(conn)
|
|
281
|
+
|
|
253
282
|
try:
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
else:
|
|
258
|
-
result, completed = await self._backend.timeout(self._dial_inner(origin), connect_timeout)
|
|
259
|
-
if not completed:
|
|
260
|
-
raise ConnectTimeout(f"Timed out connecting to {origin}")
|
|
261
|
-
conn = result
|
|
283
|
+
_, completed = await self._backend.timeout(dial(), connect_timeout)
|
|
284
|
+
if not completed:
|
|
285
|
+
raise ConnectTimeout(f"Timed out connecting to {origin}")
|
|
262
286
|
except BaseException:
|
|
287
|
+
with lock:
|
|
288
|
+
abandoned = True
|
|
289
|
+
conn = dialed.pop() if dialed else None
|
|
290
|
+
if conn is not None:
|
|
291
|
+
# the caller gave up: hand over closing
|
|
292
|
+
self._backend.spawn_detached(self._close_conn(conn))
|
|
263
293
|
self._abandon_dial(origin)
|
|
264
294
|
raise
|
|
265
|
-
return
|
|
266
|
-
|
|
267
|
-
async def _dial_inner(self, origin: Origin) -> typing.Any:
|
|
268
|
-
conn = await self._connector(origin)
|
|
269
|
-
await conn.__aenter__()
|
|
270
|
-
return conn
|
|
295
|
+
return dialed.pop()
|
|
271
296
|
|
|
272
297
|
def _abandon_dial(self, origin: Origin) -> None:
|
|
273
298
|
with self._lock:
|
|
@@ -281,9 +306,13 @@ class ConnectionPool:
|
|
|
281
306
|
event.set()
|
|
282
307
|
self._wake_waiters()
|
|
283
308
|
|
|
284
|
-
def _install(self, origin: Origin, conn: typing.Any) -> tuple[typing.Any, bool, bool]:
|
|
285
|
-
"""Record a freshly-dialed connection and settle the origin's mode.
|
|
309
|
+
def _install(self, origin: Origin, conn: typing.Any) -> tuple[typing.Any, bool, bool] | None:
|
|
310
|
+
"""Record a freshly-dialed connection and settle the origin's mode.
|
|
311
|
+
None when the pool was closed while dialing: the connection is not
|
|
312
|
+
recorded, and is the caller's to close."""
|
|
286
313
|
with self._lock:
|
|
314
|
+
if self._closed:
|
|
315
|
+
return None
|
|
287
316
|
host = self._hosts.setdefault(origin, _HostState())
|
|
288
317
|
event, host.dialing = host.dialing, None
|
|
289
318
|
if _is_multiplexed(conn):
|
|
@@ -112,6 +112,7 @@ class ProxyConnector:
|
|
|
112
112
|
transport = await self._dial_proxy(intercept)
|
|
113
113
|
conn = H1Connection(transport, authority=origin.authority, backend=self._backend)
|
|
114
114
|
setattr(conn, PROXY_ATTR, ProxyInfo(auth=intercept.basic_auth(), headers=self._config.headers))
|
|
115
|
+
await conn.__aenter__()
|
|
115
116
|
return conn
|
|
116
117
|
|
|
117
118
|
async def _tunnel(self, origin: Origin, intercept: typing.Any) -> typing.Any:
|
|
@@ -149,5 +150,8 @@ class ProxyConnector:
|
|
|
149
150
|
raise ProxyError(f"Cannot open a TLS tunnel to {origin} via proxy {intercept.uri}: {exc}")
|
|
150
151
|
|
|
151
152
|
if selected == "h2" or not self._http1:
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
conn = H2Connection(tls, authority=origin.authority, scheme="https", backend=self._backend)
|
|
154
|
+
else:
|
|
155
|
+
conn = H1Connection(tls, authority=origin.authority, backend=self._backend)
|
|
156
|
+
await conn.__aenter__()
|
|
157
|
+
return conn
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import contextlib
|
|
3
4
|
import functools
|
|
4
5
|
import threading
|
|
5
6
|
import typing
|
|
@@ -302,12 +303,37 @@ class PoolTransport:
|
|
|
302
303
|
raise TimeoutException("Exceeded the total request timeout", request=request)
|
|
303
304
|
return remaining if phase is None else min(phase, remaining)
|
|
304
305
|
|
|
306
|
+
async def _abort_response(self, response):
|
|
307
|
+
with contextlib.suppress(Exception):
|
|
308
|
+
await response.aclose()
|
|
309
|
+
|
|
305
310
|
async def _send_on(self, conn: typing.Any, request: Request, read_timeout: float | None) -> typing.Any:
|
|
306
311
|
proxy_info = getattr(conn, PROXY_ATTR, None)
|
|
307
312
|
httpunk_request = _to_httpunk_request(request, h2=_is_multiplexed(conn), proxy=proxy_info)
|
|
308
313
|
if read_timeout is None:
|
|
309
314
|
return await conn.send_request(httpunk_request)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
|
|
316
|
+
abandoned, sent, lock = False, [], threading.Lock()
|
|
317
|
+
|
|
318
|
+
async def send():
|
|
319
|
+
response = await conn.send_request(httpunk_request)
|
|
320
|
+
with lock:
|
|
321
|
+
if not abandoned:
|
|
322
|
+
sent.append(response)
|
|
323
|
+
return
|
|
324
|
+
# the caller is gone: nobody takes it
|
|
325
|
+
await self._abort_response(response)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
_, completed = await self._backend.timeout(send(), read_timeout)
|
|
329
|
+
if not completed:
|
|
330
|
+
raise ReadTimeout("Timed out waiting for the response", request=request)
|
|
331
|
+
except BaseException:
|
|
332
|
+
with lock:
|
|
333
|
+
abandoned = True
|
|
334
|
+
response = sent.pop() if sent else None
|
|
335
|
+
if response is not None:
|
|
336
|
+
# the caller gave up: hand over closing
|
|
337
|
+
self._backend.spawn_detached(self._abort_response(response))
|
|
338
|
+
raise
|
|
339
|
+
return sent.pop()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "punkreq"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "An async HTTP client built on httpunk"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "BSD-3-Clause"
|
|
11
|
-
requires-python = ">=3.
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
12
|
|
|
13
13
|
authors = [
|
|
14
14
|
{ name = "Giovanni Barillari", email = "g@baro.dev" }
|
|
@@ -22,7 +22,6 @@ classifiers = [
|
|
|
22
22
|
"Operating System :: OS Independent",
|
|
23
23
|
"Programming Language :: Python :: Free Threading :: 3 - Stable",
|
|
24
24
|
"Programming Language :: Python :: 3",
|
|
25
|
-
"Programming Language :: Python :: 3.10",
|
|
26
25
|
"Programming Language :: Python :: 3.11",
|
|
27
26
|
"Programming Language :: Python :: 3.12",
|
|
28
27
|
"Programming Language :: Python :: 3.13",
|
|
@@ -32,7 +31,7 @@ classifiers = [
|
|
|
32
31
|
]
|
|
33
32
|
|
|
34
33
|
dependencies = [
|
|
35
|
-
"httpunk~=0.
|
|
34
|
+
"httpunk~=0.5.0",
|
|
36
35
|
]
|
|
37
36
|
|
|
38
37
|
[project.optional-dependencies]
|
|
@@ -117,6 +117,7 @@ class FakeConnector:
|
|
|
117
117
|
raise punkreq.ConnectError(f"boom dialing {origin}")
|
|
118
118
|
conn = FakeConnection(self.handler, multiplexed=self.multiplexed)
|
|
119
119
|
self.connections.append(conn)
|
|
120
|
+
await conn.__aenter__() # a connector hands back an entered connection
|
|
120
121
|
return conn
|
|
121
122
|
|
|
122
123
|
@property
|
|
@@ -41,12 +41,12 @@ async def test_connector_alpn_offer_from_flags(http1, http2, offer):
|
|
|
41
41
|
context = ssl.create_default_context()
|
|
42
42
|
connector = Connector(backend=backend, ssl_context=context, http1=http1, http2=http2)
|
|
43
43
|
assert connector.alpn == offer
|
|
44
|
-
await connector(Origin("https", "example.com", 443))
|
|
44
|
+
await connector._connect_tls(Origin("https", "example.com", 443))
|
|
45
45
|
assert backend.calls == [(None, context)] # already on the context: no per-dial offer
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
async def test_connector_default_context_configured_per_dial():
|
|
49
49
|
backend = RecordingBackend("h2")
|
|
50
50
|
connector = Connector(backend=backend)
|
|
51
|
-
await connector(Origin("https", "example.com", 443))
|
|
51
|
+
await connector._connect_tls(Origin("https", "example.com", 443))
|
|
52
52
|
assert backend.calls == [(("h2", "http/1.1"), None)]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.3"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|