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.
Files changed (49) hide show
  1. {punkreq-0.2.3 → punkreq-0.3.0}/PKG-INFO +3 -4
  2. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_connect.py +13 -7
  3. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_pool.py +51 -22
  4. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_proxies.py +6 -2
  5. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_transport.py +30 -4
  6. punkreq-0.3.0/punkreq/_version.py +1 -0
  7. {punkreq-0.2.3 → punkreq-0.3.0}/pyproject.toml +3 -4
  8. {punkreq-0.2.3 → punkreq-0.3.0}/tests/fakes.py +1 -0
  9. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_connect.py +2 -2
  10. punkreq-0.2.3/punkreq/_version.py +0 -1
  11. {punkreq-0.2.3 → punkreq-0.3.0}/.gitignore +0 -0
  12. {punkreq-0.2.3 → punkreq-0.3.0}/LICENSE +0 -0
  13. {punkreq-0.2.3 → punkreq-0.3.0}/README.md +0 -0
  14. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/__init__.py +0 -0
  15. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_auth.py +0 -0
  16. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_client.py +0 -0
  17. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_config.py +0 -0
  18. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_content.py +0 -0
  19. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_cookies.py +0 -0
  20. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_decoders.py +0 -0
  21. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_exceptions.py +0 -0
  22. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_headers.py +0 -0
  23. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_models.py +0 -0
  24. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_multipart.py +0 -0
  25. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_redirects.py +0 -0
  26. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/_urls.py +0 -0
  27. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/asyncio.py +0 -0
  28. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/py.typed +0 -0
  29. {punkreq-0.2.3 → punkreq-0.3.0}/punkreq/tonio.py +0 -0
  30. {punkreq-0.2.3 → punkreq-0.3.0}/tests/__init__.py +0 -0
  31. {punkreq-0.2.3 → punkreq-0.3.0}/tests/conftest.py +0 -0
  32. {punkreq-0.2.3 → punkreq-0.3.0}/tests/e2e.py +0 -0
  33. {punkreq-0.2.3 → punkreq-0.3.0}/tests/servers.py +0 -0
  34. {punkreq-0.2.3 → punkreq-0.3.0}/tests/servers_tonio.py +0 -0
  35. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_client.py +0 -0
  36. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_config.py +0 -0
  37. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_content.py +0 -0
  38. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_cookies.py +0 -0
  39. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_decoders.py +0 -0
  40. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_e2e_asyncio.py +0 -0
  41. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_e2e_tonio.py +0 -0
  42. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_exceptions.py +0 -0
  43. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_headers.py +0 -0
  44. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_models.py +0 -0
  45. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_multipart.py +0 -0
  46. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_pool.py +0 -0
  47. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_redirects.py +0 -0
  48. {punkreq-0.2.3 → punkreq-0.3.0}/tests/test_transport.py +0 -0
  49. {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.2.3
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.10
27
- Requires-Dist: httpunk~=0.4.4
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 or a TLS handshake fails with on `backend`: the OS and ssl
87
- errors, plus the backend's own transport failure. tonio's `ResourceBroken`
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
- un-entered httpunk connection."""
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
- return await self._connect_tls(origin)
130
- return await self._connect_tcp(origin)
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; httpunk F35).
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 (httpunk D3); closing on a
19
- failed exchange instead of re-parking is httpunk's F51 choice. Like
20
- httpunk's `Cache` (F50), there is no checkout-vs-connect race: an empty
21
- idle set dials directly.
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
- return self._install(origin, conn)
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
- if connect_timeout is None:
255
- conn = await self._connector(origin)
256
- await conn.__aenter__()
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 conn
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
- return H2Connection(tls, authority=origin.authority, scheme="https", backend=self._backend)
153
- return H1Connection(tls, authority=origin.authority, backend=self._backend)
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
- result, completed = await self._backend.timeout(conn.send_request(httpunk_request), read_timeout)
311
- if not completed:
312
- raise ReadTimeout("Timed out waiting for the response", request=request)
313
- return result
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.2.3"
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.10"
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.4.4",
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