punkreq 0.2.2__tar.gz → 0.2.4__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.2 → punkreq-0.2.4}/PKG-INFO +3 -3
  2. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_connect.py +32 -14
  3. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_pool.py +46 -17
  4. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_proxies.py +6 -4
  5. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_transport.py +30 -4
  6. punkreq-0.2.4/punkreq/_version.py +1 -0
  7. {punkreq-0.2.2 → punkreq-0.2.4}/pyproject.toml +3 -3
  8. {punkreq-0.2.2 → punkreq-0.2.4}/tests/fakes.py +1 -0
  9. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_connect.py +2 -2
  10. punkreq-0.2.2/punkreq/_version.py +0 -1
  11. {punkreq-0.2.2 → punkreq-0.2.4}/.gitignore +0 -0
  12. {punkreq-0.2.2 → punkreq-0.2.4}/LICENSE +0 -0
  13. {punkreq-0.2.2 → punkreq-0.2.4}/README.md +0 -0
  14. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/__init__.py +0 -0
  15. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_auth.py +0 -0
  16. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_client.py +0 -0
  17. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_config.py +0 -0
  18. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_content.py +0 -0
  19. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_cookies.py +0 -0
  20. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_decoders.py +0 -0
  21. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_exceptions.py +0 -0
  22. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_headers.py +0 -0
  23. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_models.py +0 -0
  24. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_multipart.py +0 -0
  25. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_redirects.py +0 -0
  26. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/_urls.py +0 -0
  27. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/asyncio.py +0 -0
  28. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/py.typed +0 -0
  29. {punkreq-0.2.2 → punkreq-0.2.4}/punkreq/tonio.py +0 -0
  30. {punkreq-0.2.2 → punkreq-0.2.4}/tests/__init__.py +0 -0
  31. {punkreq-0.2.2 → punkreq-0.2.4}/tests/conftest.py +0 -0
  32. {punkreq-0.2.2 → punkreq-0.2.4}/tests/e2e.py +0 -0
  33. {punkreq-0.2.2 → punkreq-0.2.4}/tests/servers.py +0 -0
  34. {punkreq-0.2.2 → punkreq-0.2.4}/tests/servers_tonio.py +0 -0
  35. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_client.py +0 -0
  36. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_config.py +0 -0
  37. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_content.py +0 -0
  38. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_cookies.py +0 -0
  39. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_decoders.py +0 -0
  40. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_e2e_asyncio.py +0 -0
  41. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_e2e_tonio.py +0 -0
  42. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_exceptions.py +0 -0
  43. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_headers.py +0 -0
  44. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_models.py +0 -0
  45. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_multipart.py +0 -0
  46. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_pool.py +0 -0
  47. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_redirects.py +0 -0
  48. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_transport.py +0 -0
  49. {punkreq-0.2.2 → punkreq-0.2.4}/tests/test_urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: punkreq
3
- Version: 0.2.2
3
+ Version: 0.2.4
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
@@ -24,11 +24,11 @@ Classifier: Programming Language :: Python :: 3.15
24
24
  Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
25
25
  Classifier: Topic :: Internet :: WWW/HTTP
26
26
  Requires-Python: >=3.10
27
- Requires-Dist: httpunk~=0.4.0
27
+ Requires-Dist: httpunk~=0.4.4
28
28
  Provides-Extra: brotli
29
29
  Requires-Dist: brotli~=1.2.0; extra == 'brotli'
30
30
  Provides-Extra: tonio
31
- Requires-Dist: tonio~=0.9.16; (python_version >= '3.14') and extra == 'tonio'
31
+ Requires-Dist: tonio~=0.10.0; (python_version >= '3.14') and extra == 'tonio'
32
32
  Description-Content-Type: text/markdown
33
33
 
34
34
  # punkreq
@@ -4,13 +4,14 @@ 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
 
10
11
  from ._exceptions import ConnectError, UnsupportedProtocol
11
12
 
12
13
 
13
- __all__ = ["Connector", "Origin", "connect_errors", "create_ssl_context", "origin_for_url"]
14
+ __all__ = ["Connector", "Origin", "connect_errors", "create_ssl_context", "enter_connection", "origin_for_url"]
14
15
 
15
16
  DEFAULT_PORTS = {"http": 80, "https": 443}
16
17
 
@@ -83,17 +84,32 @@ 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)
95
+
96
+
97
+ async def enter_connection(conn: typing.Any, transport: typing.Any, backend: typing.Any) -> typing.Any:
98
+ """Run `conn`'s HTTP handshake (`__aenter__`) and return it. Until that
99
+ completes the transport we dialed is still ours to close: httpunk closes
100
+ it on `__aexit__`, which a failed or interrupted `__aenter__` never
101
+ reaches (h2 waits there for the peer's SETTINGS, pumps already running)."""
102
+ try:
103
+ await conn.__aenter__()
104
+ except BaseException:
105
+ backend.close_transport(transport)
106
+ raise
107
+ return conn
92
108
 
93
109
 
94
110
  class Connector:
95
- """The default connector: dial `origin` and return the protocol-matching,
96
- un-entered httpunk connection."""
111
+ """The default connector: dial `origin` and return the protocol-matching
112
+ httpunk connection, entered."""
97
113
 
98
114
  def __init__(
99
115
  self,
@@ -126,12 +142,14 @@ class Connector:
126
142
  async def __call__(self, origin: Origin) -> H1Connection | H2Connection:
127
143
  try:
128
144
  if origin.scheme == "https":
129
- return await self._connect_tls(origin)
130
- return await self._connect_tcp(origin)
145
+ conn, stream = await self._connect_tls(origin)
146
+ else:
147
+ conn, stream = await self._connect_tcp(origin)
148
+ return await enter_connection(conn, stream, self._backend)
131
149
  except connect_errors(self._backend) as exc:
132
150
  raise ConnectError(f"Failed to connect to {origin}: {exc}")
133
151
 
134
- async def _connect_tls(self, origin: Origin) -> H1Connection | H2Connection:
152
+ async def _connect_tls(self, origin: Origin) -> tuple[H1Connection | H2Connection, typing.Any]:
135
153
  # our context already carries the offer; a backend-created default one
136
154
  # (no context given) is configured per dial
137
155
  alpn = None if self._ssl_context is not None else self.alpn
@@ -139,12 +157,12 @@ class Connector:
139
157
  origin.host, origin.port, alpn=alpn, ssl_context=self._ssl_context
140
158
  )
141
159
  if selected == "h2" or not self._http1:
142
- return H2Connection(stream, authority=origin.authority, scheme="https", backend=self._backend)
143
- return H1Connection(stream, authority=origin.authority, backend=self._backend)
160
+ return H2Connection(stream, authority=origin.authority, scheme="https", backend=self._backend), stream
161
+ return H1Connection(stream, authority=origin.authority, backend=self._backend), stream
144
162
 
145
- async def _connect_tcp(self, origin: Origin) -> H1Connection | H2Connection:
163
+ async def _connect_tcp(self, origin: Origin) -> tuple[H1Connection | H2Connection, typing.Any]:
146
164
  stream = await self._backend.connect_tcp(origin.host, origin.port)
147
165
  if not self._http1:
148
166
  # h2 prior knowledge over cleartext (reqwest's http2_prior_knowledge)
149
- return H2Connection(stream, authority=origin.authority, scheme="http", backend=self._backend)
150
- return H1Connection(stream, authority=origin.authority, backend=self._backend)
167
+ return H2Connection(stream, authority=origin.authority, scheme="http", backend=self._backend), stream
168
+ return H1Connection(stream, authority=origin.authority, backend=self._backend), stream
@@ -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):
@@ -10,7 +10,7 @@ from httpunk.h2.client import H2Connection
10
10
  from httpunk.util.proxy import Matcher
11
11
 
12
12
  from ._config import Proxy
13
- from ._connect import Connector, Origin, connect_errors
13
+ from ._connect import Connector, Origin, connect_errors, enter_connection
14
14
  from ._exceptions import ConnectError, ProxyError
15
15
  from ._headers import Headers
16
16
  from ._urls import URL
@@ -112,7 +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
- return conn
115
+ return await enter_connection(conn, transport, self._backend)
116
116
 
117
117
  async def _tunnel(self, origin: Origin, intercept: typing.Any) -> typing.Any:
118
118
  transport = await self._dial_proxy(intercept)
@@ -149,5 +149,7 @@ class ProxyConnector:
149
149
  raise ProxyError(f"Cannot open a TLS tunnel to {origin} via proxy {intercept.uri}: {exc}")
150
150
 
151
151
  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)
152
+ conn = H2Connection(tls, authority=origin.authority, scheme="https", backend=self._backend)
153
+ else:
154
+ conn = H1Connection(tls, authority=origin.authority, backend=self._backend)
155
+ return await enter_connection(conn, tls, self._backend)
@@ -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.2.4"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "punkreq"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "An async HTTP client built on httpunk"
9
9
  readme = "README.md"
10
10
  license = "BSD-3-Clause"
@@ -32,12 +32,12 @@ classifiers = [
32
32
  ]
33
33
 
34
34
  dependencies = [
35
- "httpunk~=0.4.0",
35
+ "httpunk~=0.4.4",
36
36
  ]
37
37
 
38
38
  [project.optional-dependencies]
39
39
  brotli = ["brotli~=1.2.0"]
40
- tonio = ["tonio~=0.9.16; python_version >= '3.14'"]
40
+ tonio = ["tonio~=0.10.0; python_version >= '3.14'"]
41
41
 
42
42
  [dependency-groups]
43
43
  lint = [
@@ -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.2"
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