punkreq 0.2.0__tar.gz → 0.2.2__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 (65) hide show
  1. {punkreq-0.2.0 → punkreq-0.2.2}/PKG-INFO +3 -3
  2. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_connect.py +27 -8
  3. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_proxies.py +18 -105
  4. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_transport.py +28 -8
  5. punkreq-0.2.2/punkreq/_version.py +1 -0
  6. {punkreq-0.2.0 → punkreq-0.2.2}/pyproject.toml +7 -3
  7. punkreq-0.2.2/tests/__init__.py +0 -0
  8. punkreq-0.2.2/tests/conftest.py +77 -0
  9. punkreq-0.2.2/tests/e2e.py +79 -0
  10. punkreq-0.2.2/tests/fakes.py +151 -0
  11. punkreq-0.2.2/tests/servers.py +101 -0
  12. punkreq-0.2.2/tests/servers_tonio.py +161 -0
  13. punkreq-0.2.2/tests/test_client.py +448 -0
  14. punkreq-0.2.2/tests/test_config.py +108 -0
  15. punkreq-0.2.2/tests/test_connect.py +52 -0
  16. punkreq-0.2.2/tests/test_content.py +156 -0
  17. punkreq-0.2.2/tests/test_cookies.py +120 -0
  18. punkreq-0.2.2/tests/test_decoders.py +124 -0
  19. punkreq-0.2.2/tests/test_e2e_asyncio.py +43 -0
  20. punkreq-0.2.2/tests/test_e2e_tonio.py +94 -0
  21. punkreq-0.2.2/tests/test_exceptions.py +50 -0
  22. punkreq-0.2.2/tests/test_headers.py +129 -0
  23. punkreq-0.2.2/tests/test_models.py +312 -0
  24. punkreq-0.2.2/tests/test_multipart.py +112 -0
  25. punkreq-0.2.2/tests/test_pool.py +476 -0
  26. punkreq-0.2.2/tests/test_redirects.py +145 -0
  27. punkreq-0.2.2/tests/test_transport.py +330 -0
  28. punkreq-0.2.2/tests/test_urls.py +248 -0
  29. punkreq-0.2.0/punkreq/_version.py +0 -1
  30. punkreq-0.2.0/tests/test_client.py +0 -654
  31. punkreq-0.2.0/tests/test_config.py +0 -88
  32. punkreq-0.2.0/tests/test_content.py +0 -126
  33. punkreq-0.2.0/tests/test_cookies.py +0 -123
  34. punkreq-0.2.0/tests/test_decoders.py +0 -127
  35. punkreq-0.2.0/tests/test_e2e_client.py +0 -122
  36. punkreq-0.2.0/tests/test_e2e_transport.py +0 -169
  37. punkreq-0.2.0/tests/test_exceptions.py +0 -74
  38. punkreq-0.2.0/tests/test_headers.py +0 -127
  39. punkreq-0.2.0/tests/test_models.py +0 -318
  40. punkreq-0.2.0/tests/test_multipart.py +0 -95
  41. punkreq-0.2.0/tests/test_pool.py +0 -682
  42. punkreq-0.2.0/tests/test_proxies.py +0 -262
  43. punkreq-0.2.0/tests/test_redirects.py +0 -128
  44. punkreq-0.2.0/tests/test_transport.py +0 -434
  45. punkreq-0.2.0/tests/test_urls.py +0 -221
  46. {punkreq-0.2.0 → punkreq-0.2.2}/.gitignore +0 -0
  47. {punkreq-0.2.0 → punkreq-0.2.2}/LICENSE +0 -0
  48. {punkreq-0.2.0 → punkreq-0.2.2}/README.md +0 -0
  49. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/__init__.py +0 -0
  50. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_auth.py +0 -0
  51. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_client.py +0 -0
  52. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_config.py +0 -0
  53. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_content.py +0 -0
  54. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_cookies.py +0 -0
  55. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_decoders.py +0 -0
  56. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_exceptions.py +0 -0
  57. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_headers.py +0 -0
  58. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_models.py +0 -0
  59. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_multipart.py +0 -0
  60. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_pool.py +0 -0
  61. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_redirects.py +0 -0
  62. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/_urls.py +0 -0
  63. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/asyncio.py +0 -0
  64. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/py.typed +0 -0
  65. {punkreq-0.2.0 → punkreq-0.2.2}/punkreq/tonio.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: punkreq
3
- Version: 0.2.0
3
+ Version: 0.2.2
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.2.0
27
+ Requires-Dist: httpunk~=0.4.0
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.12; (python_version >= '3.14') and extra == 'tonio'
31
+ Requires-Dist: tonio~=0.9.16; (python_version >= '3.14') and extra == 'tonio'
32
32
  Description-Content-Type: text/markdown
33
33
 
34
34
  # punkreq
@@ -10,7 +10,7 @@ from httpunk.h2.client import H2Connection
10
10
  from ._exceptions import ConnectError, UnsupportedProtocol
11
11
 
12
12
 
13
- __all__ = ["Connector", "Origin", "create_ssl_context", "origin_for_url"]
13
+ __all__ = ["Connector", "Origin", "connect_errors", "create_ssl_context", "origin_for_url"]
14
14
 
15
15
  DEFAULT_PORTS = {"http": 80, "https": 443}
16
16
 
@@ -82,6 +82,15 @@ def create_ssl_context(verify: VerifyTypes = True, cert: CertTypes | None = None
82
82
  return context
83
83
 
84
84
 
85
+ 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`
88
+ is a plain `Exception`, not an `OSError`, and its TLS layer raises it in
89
+ place of the ssl error, so `(OSError, ssl.SSLError)` alone lets a failed
90
+ handshake on tonio escape as an unmapped exception."""
91
+ return (OSError, ssl.SSLError, *backend.broken_transport_errors)
92
+
93
+
85
94
  class Connector:
86
95
  """The default connector: dial `origin` and return the protocol-matching,
87
96
  un-entered httpunk connection."""
@@ -100,22 +109,32 @@ class Connector:
100
109
  self._ssl_context = ssl_context
101
110
  self._http1 = http1
102
111
  self._http2 = http2
112
+ if http1 and http2:
113
+ self.alpn: tuple[str, ...] = ("h2", "http/1.1")
114
+ elif http2:
115
+ self.alpn = ("h2",)
116
+ else:
117
+ self.alpn = ("http/1.1",)
118
+ # The ALPN offer is configured on the context ONCE, here: a context is
119
+ # shared by every dial that uses it (and by `ProxyConnector`'s CONNECT
120
+ # tunnels, whose `wrap_tls` takes ALPN from the context alone), so the
121
+ # dial path never mutates it (hyper-util / httpunk `util.connect`
122
+ # discipline: a caller-supplied context is never touched per connect).
123
+ if ssl_context is not None:
124
+ ssl_context.set_alpn_protocols(list(self.alpn))
103
125
 
104
126
  async def __call__(self, origin: Origin) -> H1Connection | H2Connection:
105
127
  try:
106
128
  if origin.scheme == "https":
107
129
  return await self._connect_tls(origin)
108
130
  return await self._connect_tcp(origin)
109
- except (OSError, ssl.SSLError) as exc:
131
+ except connect_errors(self._backend) as exc:
110
132
  raise ConnectError(f"Failed to connect to {origin}: {exc}")
111
133
 
112
134
  async def _connect_tls(self, origin: Origin) -> H1Connection | H2Connection:
113
- if self._http1 and self._http2:
114
- alpn: tuple[str, ...] = ("h2", "http/1.1")
115
- elif self._http2:
116
- alpn = ("h2",)
117
- else:
118
- alpn = ("http/1.1",)
135
+ # our context already carries the offer; a backend-created default one
136
+ # (no context given) is configured per dial
137
+ alpn = None if self._ssl_context is not None else self.alpn
119
138
  stream, selected = await self._backend.connect_tls(
120
139
  origin.host, origin.port, alpn=alpn, ssl_context=self._ssl_context
121
140
  )
@@ -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
13
+ from ._connect import Connector, Origin, connect_errors
14
14
  from ._exceptions import ConnectError, ProxyError
15
15
  from ._headers import Headers
16
16
  from ._urls import URL
@@ -55,89 +55,6 @@ class ProxyConfig:
55
55
  return None
56
56
 
57
57
 
58
- class TunnelTLSStream:
59
- """TLS over an arbitrary bidirectional stream (the CONNECT tunnel), via
60
- `ssl.MemoryBIO`. Presents the httpunk stream interface (`receive_some`,
61
- `send_all`, `close`, `read_nowait`) so H1/H2 connections sit on it directly."""
62
-
63
- def __init__(
64
- self,
65
- inner: typing.Any,
66
- context: ssl.SSLContext,
67
- *,
68
- server_hostname: str,
69
- closer: typing.Callable[[], None],
70
- ) -> None:
71
- self._inner = inner
72
- self._incoming = ssl.MemoryBIO()
73
- self._outgoing = ssl.MemoryBIO()
74
- self._ssl = context.wrap_bio(self._incoming, self._outgoing, server_hostname=server_hostname)
75
- self._closer = closer
76
- self._closed = False
77
-
78
- async def handshake(self) -> None:
79
- while True:
80
- try:
81
- self._ssl.do_handshake()
82
- break
83
- except ssl.SSLWantReadError:
84
- await self._flush()
85
- data = await self._inner.receive_some(65536)
86
- if not data:
87
- raise ssl.SSLError("connection closed during TLS handshake")
88
- self._incoming.write(data)
89
- await self._flush()
90
-
91
- def selected_alpn_protocol(self) -> str | None:
92
- return self._ssl.selected_alpn_protocol()
93
-
94
- async def _flush(self) -> None:
95
- data = self._outgoing.read()
96
- if data:
97
- await self._inner.send_all(data)
98
-
99
- async def receive_some(self, max_bytes: int = 65536) -> bytes:
100
- while True:
101
- try:
102
- return self._ssl.read(max_bytes)
103
- except ssl.SSLWantReadError:
104
- await self._flush()
105
- raw = await self._inner.receive_some(65536)
106
- if not raw:
107
- return b"" # ragged EOF: treated as end-of-stream
108
- self._incoming.write(raw)
109
- except ssl.SSLZeroReturnError:
110
- return b""
111
-
112
- async def send_all(self, data: bytes) -> None:
113
- view = memoryview(data)
114
- while view:
115
- try:
116
- sent = self._ssl.write(view)
117
- except ssl.SSLWantReadError:
118
- raw = await self._inner.receive_some(65536)
119
- if not raw:
120
- raise ssl.SSLError("connection closed during TLS write")
121
- self._incoming.write(raw)
122
- continue
123
- await self._flush()
124
- view = view[sent:]
125
- await self._flush()
126
-
127
- def read_nowait(self, max_bytes: int = 65536) -> bytes:
128
- """Decrypted bytes already buffered in the SSL object, without touching
129
- the tunnel (the h1 driver's pre-request unexpected-bytes check)."""
130
- try:
131
- return self._ssl.read(max_bytes)
132
- except (ssl.SSLWantReadError, ssl.SSLZeroReturnError):
133
- return b""
134
-
135
- def close(self) -> None:
136
- if not self._closed:
137
- self._closed = True
138
- self._closer()
139
-
140
-
141
58
  class ProxyConnector:
142
59
  """Connector that routes each origin through its matched proxy, delegating
143
60
  unmatched origins to the direct `Connector`."""
@@ -156,16 +73,9 @@ class ProxyConnector:
156
73
  self._http1 = http1
157
74
  self._http2 = http2
158
75
  self._config = config
76
+ # the direct connector configures the ALPN offer on `ssl_context` at
77
+ # construction; `wrap_tls` (the CONNECT tunnel) takes it from the context
159
78
  self._direct = Connector(backend=backend, ssl_context=ssl_context, http1=http1, http2=http2)
160
- if http1 and http2:
161
- alpn: list[str] = ["h2", "http/1.1"]
162
- elif http2:
163
- alpn = ["h2"]
164
- else:
165
- alpn = ["http/1.1"]
166
- # wrap_bio takes ALPN from the context; the direct dial path sets the
167
- # same list on the same context, so this stays consistent
168
- ssl_context.set_alpn_protocols(alpn)
169
79
 
170
80
  async def __call__(self, origin: Origin) -> typing.Any:
171
81
  intercept = self._config.matcher.intercept(f"{origin.scheme}://{origin.authority}/")
@@ -175,7 +85,7 @@ class ProxyConnector:
175
85
  if origin.scheme == "http":
176
86
  return await self._absolute_form(origin, intercept)
177
87
  return await self._tunnel(origin, intercept)
178
- except (OSError, ssl.SSLError) as exc:
88
+ except connect_errors(self._backend) as exc:
179
89
  raise ConnectError(f"Failed to connect to {origin} via proxy {intercept.uri}: {exc}")
180
90
 
181
91
  async def _dial_proxy(self, intercept: typing.Any) -> typing.Any:
@@ -223,18 +133,21 @@ class ProxyConnector:
223
133
  self._backend.close_transport(transport)
224
134
  raise
225
135
 
226
- tls = TunnelTLSStream(
227
- tunnel,
228
- self._ssl_context,
229
- server_hostname=origin.host,
230
- closer=lambda: self._backend.close_transport(transport),
231
- )
136
+ # The tunnel's IO, and whatever was read past the CONNECT response (the
137
+ # start of the origin's TLS conversation), go to the backend's TLS layer
138
+ # (httpunk >= 0.4.0). On a handshake failure the backend closes the
139
+ # transport itself before raising; `__call__` maps the error.
140
+ stream, read_buf = tunnel.downcast()
232
141
  try:
233
- await tls.handshake()
234
- except ssl.SSLError as exc:
235
- self._backend.close_transport(transport)
236
- raise ConnectError(f"TLS handshake with {origin} through the proxy failed: {exc}")
142
+ tls, selected = await self._backend.wrap_tls(
143
+ stream, server_hostname=origin.host, ssl_context=self._ssl_context, prefix=read_buf
144
+ )
145
+ except TypeError as exc:
146
+ # the backend refuses the stream at setup (tonio: no TLS over TLS, so an
147
+ # https:// proxy cannot carry a CONNECT tunnel there)
148
+ self._backend.close_transport(stream)
149
+ raise ProxyError(f"Cannot open a TLS tunnel to {origin} via proxy {intercept.uri}: {exc}")
237
150
 
238
- if tls.selected_alpn_protocol() == "h2" or not self._http1:
151
+ if selected == "h2" or not self._http1:
239
152
  return H2Connection(tls, authority=origin.authority, scheme="https", backend=self._backend)
240
153
  return H1Connection(tls, authority=origin.authority, backend=self._backend)
@@ -6,7 +6,14 @@ import typing
6
6
 
7
7
  import httpunk
8
8
  from httpunk import GoAwayError, H2Reason, StreamResetError
9
- from httpunk.exceptions import ConnectionClosedError, H2Error, H2UserError, HTTPunkError
9
+ from httpunk.exceptions import (
10
+ ConnectionClosedError,
11
+ H1IncompleteMessageError,
12
+ H1UserError,
13
+ H2Error,
14
+ H2UserError,
15
+ HTTPunkError,
16
+ )
10
17
 
11
18
  from ._config import Timeout
12
19
  from ._connect import origin_for_url
@@ -30,6 +37,12 @@ __all__ = ["PoolTransport"]
30
37
 
31
38
  _MAX_NACK_RETRIES = 2
32
39
 
40
+ # The peer went away with our exchange in flight: the transport failed (hyper's
41
+ # `Io`, both protocols), or on h1 it closed cleanly before the response head
42
+ # (hyper's `IncompleteMessage`, httpunk >= 0.3.0 — a sibling under `H1Error`, not
43
+ # a `ConnectionClosedError`). The same "server disconnected" verdict either way.
44
+ _DISCONNECTED = (ConnectionClosedError, H1IncompleteMessageError)
45
+
33
46
 
34
47
  def map_httpunk_exception(exc: BaseException, request: Request) -> BaseException:
35
48
  """Translate an httpunk (or OS-level) failure into the punkreq hierarchy."""
@@ -39,9 +52,11 @@ def map_httpunk_exception(exc: BaseException, request: Request) -> BaseException
39
52
  return exc
40
53
  message = str(exc) or type(exc).__name__
41
54
  mapped: HTTPError
42
- if isinstance(exc, (H2UserError, ValueError)):
55
+ if isinstance(exc, (H1UserError, H2UserError, ValueError)):
56
+ # local misuse: hyper's `Kind::User` on h1, the h2 state machine's API
57
+ # errors, and the `http` crate's constructor validation (`ValueError`)
43
58
  mapped = LocalProtocolError(message)
44
- elif isinstance(exc, ConnectionClosedError):
59
+ elif isinstance(exc, _DISCONNECTED):
45
60
  mapped = RemoteProtocolError(f"Server disconnected: {message}")
46
61
  elif isinstance(exc, (H2Error, HTTPunkError)):
47
62
  mapped = RemoteProtocolError(message)
@@ -65,12 +80,17 @@ def _is_retryable_nack(exc: BaseException, reused: bool, replayable: bool) -> bo
65
80
  # httpunk >= 0.2.0 (h1): the failure was raised before the request was
66
81
  # handed to the body writer — nothing reached the wire and the body
67
82
  # was never iterated, so even a streamed body is intact and safe to
68
- # resend (hyper's `TrySendError` request give-back; the `reused` gate
69
- # mirrors hyper-util's "a fresh connection means we can't retry").
83
+ # resend (hyper's `try_send_request` give-back, client/conn/http1.rs:
84
+ # an error "before trying to serialize the request" returns the
85
+ # message). The `reused` gate is punkreq's own policy: a give-back on a
86
+ # fresh connection means the origin is failing at dial time, and a
87
+ # redial would only fail the same way — only an idle keep-alive
88
+ # connection dying underneath us is a nack worth one more try.
70
89
  return reused
71
- if isinstance(exc, ConnectionClosedError):
90
+ if isinstance(exc, _DISCONNECTED):
72
91
  # h1 keep-alive race past the writer hand-off: the server closed the
73
- # connection as we reused it, but body bytes may have been consumed.
92
+ # connection as we reused it (EOF before the response head, hyper's
93
+ # `IncompleteMessage`), but body bytes may have been consumed.
74
94
  # Only safe when the connection had served traffic before AND the body
75
95
  # replays from memory.
76
96
  return reused and replayable
@@ -269,7 +289,7 @@ class PoolTransport:
269
289
  headers=Headers(httpunk_response.headers),
270
290
  stream=stream,
271
291
  request=request,
272
- http_version="HTTP/2" if _is_multiplexed(conn) else "HTTP/1.1",
292
+ http_version=str(httpunk_response.version), # httpunk.Version, `http::Version` names
273
293
  )
274
294
 
275
295
  def _effective(self, phase: float | None, deadline: float | None, request: Request) -> float | None:
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "punkreq"
7
- version = "0.2.0"
7
+ version = "0.2.2"
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.2.0",
35
+ "httpunk~=0.4.0",
36
36
  ]
37
37
 
38
38
  [project.optional-dependencies]
39
39
  brotli = ["brotli~=1.2.0"]
40
- tonio = ["tonio~=0.9.12; python_version >= '3.14'"]
40
+ tonio = ["tonio~=0.9.16; python_version >= '3.14'"]
41
41
 
42
42
  [dependency-groups]
43
43
  lint = [
@@ -45,6 +45,7 @@ lint = [
45
45
  ]
46
46
  test = [
47
47
  "pytest>=7.4",
48
+ "pytest-asyncio>=1.0",
48
49
  "trustme>=1.2.1",
49
50
  ]
50
51
 
@@ -54,6 +55,9 @@ Funding = 'https://github.com/sponsors/gi0baro'
54
55
  Source = 'https://github.com/gi0baro/punkreq'
55
56
  Issues = 'https://github.com/gi0baro/punkreq/issues'
56
57
 
58
+ [tool.pytest.ini_options]
59
+ asyncio_mode = "strict"
60
+
57
61
  [tool.hatch.build.targets.sdist]
58
62
  include = [
59
63
  '/README.md',
File without changes
@@ -0,0 +1,77 @@
1
+ import pytest
2
+ import pytest_asyncio
3
+ import trustme
4
+ from httpunk import Backend
5
+
6
+ from punkreq import Limits
7
+ from punkreq._pool import ConnectionPool
8
+ from punkreq.asyncio import Client
9
+ from tests.fakes import FakeConnector
10
+ from tests.servers import RawProxy, start_server
11
+
12
+
13
+ @pytest.fixture(scope="session")
14
+ def ca():
15
+ """A throwaway certificate authority for TLS peers."""
16
+ return trustme.CA()
17
+
18
+
19
+ @pytest.fixture
20
+ def make_client():
21
+ """`(client, connector)`: a Client over a fake connector whose connections
22
+ answer every request from `handler(request, conn)`."""
23
+
24
+ def factory(handler, *, multiplexed=False, **kwargs):
25
+ connector = FakeConnector(handler, multiplexed=multiplexed)
26
+ return Client(connector=connector, **kwargs), connector
27
+
28
+ return factory
29
+
30
+
31
+ @pytest.fixture
32
+ def make_pool():
33
+ """A ConnectionPool over `connector` on the asyncio backend (or `backend`),
34
+ with `Limits(**limits)`."""
35
+
36
+ def factory(connector, *, backend=None, **limits):
37
+ return ConnectionPool(connector, backend=backend or Backend.asyncio.create(), limits=Limits(**limits))
38
+
39
+ return factory
40
+
41
+
42
+ # ----- real peers on the asyncio test's own loop (`@pytest.mark.asyncio` tests only) -----
43
+
44
+
45
+ @pytest_asyncio.fixture
46
+ async def echo_server():
47
+ """`await echo_server(ssl_context=None) -> port`: a JSON echo server on this
48
+ test's loop; every server started is closed at teardown."""
49
+ servers = []
50
+
51
+ async def start(ssl_context=None):
52
+ server, port = await start_server(ssl_context=ssl_context)
53
+ servers.append(server)
54
+ return port
55
+
56
+ yield start
57
+ for server in servers:
58
+ server.close()
59
+ if hasattr(server, "close_clients"): # 3.13+: `wait_closed` waits for clients too
60
+ server.close_clients()
61
+ await server.wait_closed()
62
+
63
+
64
+ @pytest_asyncio.fixture
65
+ async def proxy():
66
+ """`await proxy(connect_status=200) -> RawProxy`: a CONNECT proxy on this
67
+ test's loop; every proxy started is stopped at teardown."""
68
+ proxies = []
69
+
70
+ async def start(connect_status=200):
71
+ started = await RawProxy(connect_status).start()
72
+ proxies.append(started)
73
+ return started
74
+
75
+ yield start
76
+ for started in proxies:
77
+ await started.stop()
@@ -0,0 +1,79 @@
1
+ """End-to-end scenarios shared by the asyncio and tonio suites: each takes the
2
+ backend's `Client` class and the peers the suite started, so both backends run
3
+ the same body against their own runtime."""
4
+
5
+ import pytest
6
+
7
+ import punkreq
8
+
9
+
10
+ async def client_h1_get_and_post_with_keepalive(client_cls, upstream_port):
11
+ async with client_cls(base_url=f"http://127.0.0.1:{upstream_port}") as client:
12
+ response = await client.get("/hello", params={"a": "1"})
13
+ assert response.status_code == 200
14
+ echo = await response.json()
15
+ assert echo["method"] == "GET"
16
+ assert echo["path"] == "/hello?a=1"
17
+ assert echo["headers"]["host"] == f"127.0.0.1:{upstream_port}"
18
+ assert echo["headers"]["user-agent"].startswith("python-punkreq/")
19
+
20
+ response = await client.post("/items", json={"name": "x"})
21
+ assert (await response.json())["body"] == '{"name":"x"}'
22
+ assert client._pool.connection_count == 1 # keep-alive reuse
23
+
24
+
25
+ async def client_h2_prior_knowledge(client_cls, upstream_port):
26
+ async with client_cls(base_url=f"http://127.0.0.1:{upstream_port}", http1=False) as client:
27
+ first = await client.get("/a")
28
+ second = await client.get("/b")
29
+ assert first.http_version == "HTTP/2"
30
+ assert {(await first.json())["path"], (await second.json())["path"]} == {"/a", "/b"}
31
+ assert client._pool.connection_count == 1 # one multiplexed connection
32
+
33
+
34
+ async def tunnel_https_h1(client_cls, ca, upstream_port, proxy):
35
+ with ca.cert_pem.tempfile() as ca_path:
36
+ async with client_cls(proxy=f"http://127.0.0.1:{proxy.port}", verify=ca_path) as client:
37
+ response = await client.get(f"https://127.0.0.1:{upstream_port}/tunneled")
38
+ assert response.status_code == 200
39
+ assert (await response.json())["path"] == "/tunneled"
40
+ assert response.http_version == "HTTP/1.1"
41
+ # a second request reuses the tunnel: the h1 send-time peek runs on the wrapped stream
42
+ response = await client.get(f"https://127.0.0.1:{upstream_port}/again")
43
+ assert (await response.json())["path"] == "/again"
44
+ assert client._pool.connection_count == 1
45
+ assert proxy.connect_targets == [f"127.0.0.1:{upstream_port}"]
46
+
47
+
48
+ async def tunnel_https_h2(client_cls, ca, upstream_port, proxy):
49
+ with ca.cert_pem.tempfile() as ca_path:
50
+ async with client_cls(proxy=f"http://127.0.0.1:{proxy.port}", verify=ca_path) as client:
51
+ first = await client.get(f"https://127.0.0.1:{upstream_port}/a")
52
+ second = await client.get(f"https://127.0.0.1:{upstream_port}/b")
53
+ assert first.http_version == "HTTP/2"
54
+ assert {(await first.json())["path"], (await second.json())["path"]} == {"/a", "/b"}
55
+ assert client._pool.connection_count == 1 # one multiplexed tunnel
56
+
57
+
58
+ async def tunnel_handshake_failure_raises_connect_error(client_cls, upstream_port, proxy):
59
+ """The origin's certificate is not trusted (system store): the backend's TLS
60
+ layer fails the handshake inside the tunnel and closes it; punkreq reports it
61
+ as a connect failure, like a failed direct dial."""
62
+ async with client_cls(proxy=f"http://127.0.0.1:{proxy.port}") as client:
63
+ with pytest.raises(punkreq.ConnectError, match="via proxy"):
64
+ await client.get(f"https://127.0.0.1:{upstream_port}/untrusted")
65
+ assert proxy.connect_targets == [f"127.0.0.1:{upstream_port}"]
66
+
67
+
68
+ async def direct_handshake_failure_raises_connect_error(client_cls, upstream_port):
69
+ """The same on a direct dial: whatever the backend raises for a failed
70
+ handshake surfaces as punkreq's `ConnectError`."""
71
+ async with client_cls() as client:
72
+ with pytest.raises(punkreq.ConnectError):
73
+ await client.get(f"https://127.0.0.1:{upstream_port}/untrusted")
74
+
75
+
76
+ async def tunnel_refused_raises_proxy_error(client_cls, proxy):
77
+ async with client_cls(proxy=f"http://127.0.0.1:{proxy.port}") as client:
78
+ with pytest.raises(punkreq.ProxyError):
79
+ await client.get("https://denied.example/")
@@ -0,0 +1,151 @@
1
+ """Stand-ins for the httpunk objects punkreq drives: a response, a pool-facing
2
+ connection and a connector, plus a backend wrapper with a deterministic clock."""
3
+
4
+ import asyncio
5
+
6
+ from httpunk import HeaderMap, Version
7
+
8
+ import punkreq
9
+
10
+
11
+ class FakeHttpunkResponse:
12
+ """The slice of an httpunk response punkreq reads: status, headers, version
13
+ and a body iterated once through `aiter_bytes`, then `aclose`d. Records how
14
+ it was driven (`aclose_calls`, `iter_finalized`); `aclose_error` makes the
15
+ close itself fail."""
16
+
17
+ def __init__(self, status=200, headers=None, body=b"", *, chunk_delay=0.0, aclose_error=None):
18
+ self.status = status
19
+ self.headers = HeaderMap(headers or {})
20
+ self.version = Version.HTTP_11 # stamped per protocol by FakeConnection.send_request
21
+ self._chunks = [body] if isinstance(body, bytes) else list(body)
22
+ self._chunk_delay = chunk_delay
23
+ self._aclose_error = aclose_error
24
+ self._conn = None # set by FakeConnection.send_request
25
+ self._consumed = False
26
+ self.closed = False
27
+ self.aclose_calls = 0
28
+ self.iter_finalized = False
29
+
30
+ async def aiter_bytes(self):
31
+ try:
32
+ for chunk in self._chunks:
33
+ if self._chunk_delay:
34
+ await asyncio.sleep(self._chunk_delay)
35
+ yield chunk
36
+ self._consumed = True
37
+ finally:
38
+ self.iter_finalized = True
39
+
40
+ async def aclose(self):
41
+ self.aclose_calls += 1
42
+ self.closed = True
43
+ # mirror httpunk h1: aborting a partially-read body closes the connection
44
+ if not self._consumed and self._conn is not None and not self._conn.multiplexed:
45
+ self._conn.closed = True
46
+ if self._aclose_error is not None:
47
+ raise self._aclose_error
48
+
49
+
50
+ class FakeConnection:
51
+ """A pool-facing connection. `handler(request, conn)` answers each
52
+ `send_request` (a FakeHttpunkResponse, an awaitable of one, or by raising); with no
53
+ handler a streamed request body is drained and a 200 "ok" returned.
54
+ `fail_next` makes the next send raise that exception instead."""
55
+
56
+ def __init__(self, handler=None, *, multiplexed=False):
57
+ self.handler = handler
58
+ self.multiplexed = multiplexed
59
+ self.closed = False
60
+ self.busy = False # httpunk: an exchange still holds the in-flight slot
61
+ self.entered = False
62
+ self.fail_next = None
63
+ self.requests = []
64
+ self.bodies_touched = 0
65
+
66
+ async def __aenter__(self):
67
+ self.entered = True
68
+ return self
69
+
70
+ async def __aexit__(self, exc_type, exc_value, exc_tb):
71
+ self.closed = True
72
+ return False
73
+
74
+ async def ready(self):
75
+ if self.closed:
76
+ raise RuntimeError("connection is closed")
77
+
78
+ async def send_request(self, request):
79
+ self.requests.append(request)
80
+ if self.fail_next is not None:
81
+ exc, self.fail_next = self.fail_next, None
82
+ raise exc
83
+ if self.handler is not None:
84
+ result = self.handler(request, self)
85
+ if asyncio.iscoroutine(result):
86
+ result = await result
87
+ else:
88
+ if request.body is not None and not isinstance(request.body, bytes):
89
+ self.bodies_touched += 1
90
+ async for _ in request.body:
91
+ pass
92
+ result = FakeHttpunkResponse(200, body=b"ok")
93
+ if isinstance(result, FakeHttpunkResponse):
94
+ result._conn = self
95
+ result.version = Version.HTTP_2 if self.multiplexed else Version.HTTP_11
96
+ return result
97
+
98
+
99
+ class FakeConnector:
100
+ """Dials a fresh FakeConnection per call and records them. `delay` sleeps
101
+ before each dial; `fail` makes that many dials raise ConnectError first."""
102
+
103
+ def __init__(self, handler=None, *, multiplexed=False, delay=0.0, fail=0):
104
+ self.handler = handler
105
+ self.multiplexed = multiplexed
106
+ self.delay = delay
107
+ self.fail = fail
108
+ self.dials = 0
109
+ self.connections = []
110
+
111
+ async def __call__(self, origin):
112
+ self.dials += 1
113
+ if self.delay:
114
+ await asyncio.sleep(self.delay)
115
+ if self.fail > 0:
116
+ self.fail -= 1
117
+ raise punkreq.ConnectError(f"boom dialing {origin}")
118
+ conn = FakeConnection(self.handler, multiplexed=self.multiplexed)
119
+ self.connections.append(conn)
120
+ return conn
121
+
122
+ @property
123
+ def requests(self):
124
+ """Every request sent, across all connections, in order."""
125
+ return [request for conn in self.connections for request in conn.requests]
126
+
127
+
128
+ def ok_handler(body=b"hello", headers=None):
129
+ """A connection handler answering every request with one 200 response."""
130
+
131
+ def handler(request, conn):
132
+ return FakeHttpunkResponse(200, headers=headers, body=body)
133
+
134
+ return handler
135
+
136
+
137
+ class SteppingBackend:
138
+ """Delegates to a real backend, but `monotonic()` strictly increases on every
139
+ call: on coarse OS clocks (Windows, ~16ms) consecutive releases can get equal
140
+ idle timestamps, making eviction order tie-dependent."""
141
+
142
+ def __init__(self, backend):
143
+ self._backend = backend
144
+ self._now = 0.0
145
+
146
+ def monotonic(self):
147
+ self._now += 1.0
148
+ return self._now
149
+
150
+ def __getattr__(self, name):
151
+ return getattr(self._backend, name)