httpdex-core 0.1.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.
@@ -0,0 +1,6 @@
1
+ .coverage
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ target/
6
+ .venv/
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.4
2
+ Name: httpdex-core
3
+ Version: 0.1.0
4
+ Summary: Async/sync HTTP connection pool. Replaces httpcore.
5
+ Author: Marcelo Trylesinski
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: anyio>=4.0
9
+ Requires-Dist: certifi
10
+ Requires-Dist: httpdex-h2>=0.1.0
11
+ Requires-Dist: httpdex-h3>=0.1.0
12
+ Requires-Dist: httpdex-parse>=0.1.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # httpdex-core
16
+
17
+ Core transport primitives for `httpdex`.
18
+
19
+ `httpdex-core` provides the async and sync connection pools, per-protocol connection implementations, request/response models, and backend abstractions used by the top-level client.
20
+
21
+ ## Highlights
22
+
23
+ - Async and sync connection pools
24
+ - HTTP/1.1, HTTP/2, and HTTP/3 connection implementations
25
+ - Shared request and response models
26
+ - Small public surface for building custom transports or experiments
27
+
28
+ ## Public API
29
+
30
+ - `AsyncConnectionPool`
31
+ - `SyncConnectionPool`
32
+ - `AsyncHTTP11Connection`, `AsyncHTTP2Connection`, `AsyncHTTP3Connection`
33
+ - `SyncHTTP11Connection`, `SyncHTTP2Connection`, `SyncHTTP3Connection`
34
+ - `Request`, `Response`, `URL`, `Origin`
35
+
36
+ ## Role In The Workspace
37
+
38
+ This package is the transport layer that sits below `httpdex` and above the protocol-specific parser and framing packages.
@@ -0,0 +1,24 @@
1
+ # httpdex-core
2
+
3
+ Core transport primitives for `httpdex`.
4
+
5
+ `httpdex-core` provides the async and sync connection pools, per-protocol connection implementations, request/response models, and backend abstractions used by the top-level client.
6
+
7
+ ## Highlights
8
+
9
+ - Async and sync connection pools
10
+ - HTTP/1.1, HTTP/2, and HTTP/3 connection implementations
11
+ - Shared request and response models
12
+ - Small public surface for building custom transports or experiments
13
+
14
+ ## Public API
15
+
16
+ - `AsyncConnectionPool`
17
+ - `SyncConnectionPool`
18
+ - `AsyncHTTP11Connection`, `AsyncHTTP2Connection`, `AsyncHTTP3Connection`
19
+ - `SyncHTTP11Connection`, `SyncHTTP2Connection`, `SyncHTTP3Connection`
20
+ - `Request`, `Response`, `URL`, `Origin`
21
+
22
+ ## Role In The Workspace
23
+
24
+ This package is the transport layer that sits below `httpdex` and above the protocol-specific parser and framing packages.
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from httpdex_core._connection import AsyncHTTP11Connection, SyncHTTP11Connection
4
+ from httpdex_core._exceptions import (
5
+ ConnectError,
6
+ ConnectionError,
7
+ ConnectTimeout,
8
+ PoolTimeout,
9
+ ReadError,
10
+ ReadTimeout,
11
+ UnsupportedProtocol,
12
+ WriteError,
13
+ WriteTimeout,
14
+ )
15
+ from httpdex_core._h2_connection import AsyncHTTP2Connection, SyncHTTP2Connection
16
+ from httpdex_core._h3_connection import AsyncHTTP3Connection, SyncHTTP3Connection
17
+ from httpdex_core._mock import MockAsyncBackend, MockSyncBackend
18
+ from httpdex_core._models import URL, AsyncResponseStream, Origin, Request, Response, SyncResponseStream
19
+ from httpdex_core._pool import AsyncConnectionPool, SyncConnectionPool
20
+
21
+ __all__ = [
22
+ "AsyncConnectionPool",
23
+ "AsyncHTTP11Connection",
24
+ "AsyncHTTP2Connection",
25
+ "AsyncHTTP3Connection",
26
+ "AsyncResponseStream",
27
+ "ConnectError",
28
+ "ConnectTimeout",
29
+ "ConnectionError",
30
+ "MockAsyncBackend",
31
+ "MockSyncBackend",
32
+ "Origin",
33
+ "PoolTimeout",
34
+ "ReadError",
35
+ "ReadTimeout",
36
+ "Request",
37
+ "Response",
38
+ "SyncConnectionPool",
39
+ "SyncHTTP11Connection",
40
+ "SyncResponseStream",
41
+ "SyncHTTP2Connection",
42
+ "SyncHTTP3Connection",
43
+ "URL",
44
+ "UnsupportedProtocol",
45
+ "WriteError",
46
+ "WriteTimeout",
47
+ ]
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import ssl
5
+
6
+ import anyio
7
+ import anyio.abc
8
+ from anyio.streams.tls import TLSStream
9
+
10
+ from httpdex_core._exceptions import ConnectError, ConnectTimeout, ReadError, ReadTimeout, WriteError, WriteTimeout
11
+
12
+
13
+ class AsyncBackend:
14
+ async def connect(
15
+ self,
16
+ host: str,
17
+ port: int,
18
+ *,
19
+ timeout: float | None = None,
20
+ ssl_context: ssl.SSLContext | None = None,
21
+ local_address: str | None = None,
22
+ ) -> AsyncStream:
23
+ try:
24
+ with anyio.fail_after(timeout):
25
+ stream = await anyio.connect_tcp(
26
+ remote_host=host,
27
+ remote_port=port,
28
+ local_host=local_address,
29
+ )
30
+ if ssl_context is not None:
31
+ tls_hostname = host.rstrip(".")
32
+ tls_stream = await TLSStream.wrap(
33
+ stream, ssl_context=ssl_context, hostname=tls_hostname, standard_compatible=False
34
+ )
35
+ return AsyncStream(tls_stream)
36
+ return AsyncStream(stream)
37
+ except TimeoutError as exc:
38
+ raise ConnectTimeout(str(exc)) from None
39
+ except OSError as exc:
40
+ raise ConnectError(str(exc)) from None
41
+
42
+
43
+ class AsyncStream:
44
+ def __init__(self, stream: anyio.abc.ByteStream) -> None:
45
+ self._stream = stream
46
+
47
+ @property
48
+ def alpn_protocol(self) -> str | None:
49
+ if isinstance(self._stream, TLSStream):
50
+ ssl_object = self._stream.extra(anyio.abc.TLSAttribute.ssl_object)
51
+ if ssl_object is not None:
52
+ return ssl_object.selected_alpn_protocol() # type: ignore[union-attr]
53
+ return None
54
+
55
+ async def read(self, max_bytes: int, *, timeout: float | None = None) -> bytes:
56
+ try:
57
+ with anyio.fail_after(timeout):
58
+ data = await self._stream.receive(max_bytes)
59
+ return data
60
+ except anyio.EndOfStream:
61
+ return b""
62
+ except TimeoutError as exc:
63
+ raise ReadTimeout(str(exc)) from None
64
+ except anyio.ClosedResourceError as exc:
65
+ raise ReadError(str(exc)) from None
66
+
67
+ async def write(self, data: bytes, *, timeout: float | None = None) -> None:
68
+ try:
69
+ with anyio.fail_after(timeout):
70
+ view = memoryview(data)
71
+ await self._stream.send(bytes(view))
72
+ except TimeoutError as exc:
73
+ raise WriteTimeout(str(exc)) from None
74
+ except anyio.ClosedResourceError as exc:
75
+ raise WriteError(str(exc)) from None
76
+
77
+ async def aclose(self) -> None:
78
+ await self._stream.aclose()
79
+
80
+ def is_readable(self) -> bool:
81
+ # Intentionally no socket poll here - checked only on use.
82
+ return True
83
+
84
+
85
+ class SyncBackend:
86
+ def connect(
87
+ self,
88
+ host: str,
89
+ port: int,
90
+ *,
91
+ timeout: float | None = None,
92
+ ssl_context: ssl.SSLContext | None = None,
93
+ local_address: str | None = None,
94
+ ) -> SyncStream:
95
+ try:
96
+ sock = socket.create_connection(
97
+ (host, port),
98
+ timeout=timeout,
99
+ source_address=(local_address, 0) if local_address else None,
100
+ )
101
+ sock.settimeout(None)
102
+ if ssl_context is not None:
103
+ tls_hostname = host.rstrip(".")
104
+ sock = ssl_context.wrap_socket(sock, server_hostname=tls_hostname)
105
+ return SyncStream(sock)
106
+ except TimeoutError as exc:
107
+ raise ConnectTimeout(str(exc)) from None
108
+ except OSError as exc:
109
+ raise ConnectError(str(exc)) from None
110
+
111
+
112
+ class SyncStream:
113
+ def __init__(self, sock: socket.socket | ssl.SSLSocket) -> None:
114
+ self._sock = sock
115
+
116
+ @property
117
+ def alpn_protocol(self) -> str | None:
118
+ if isinstance(self._sock, ssl.SSLSocket):
119
+ return self._sock.selected_alpn_protocol()
120
+ return None
121
+
122
+ def read(self, max_bytes: int, *, timeout: float | None = None) -> bytes:
123
+ self._sock.settimeout(timeout)
124
+ try:
125
+ data = self._sock.recv(max_bytes)
126
+ return data
127
+ except TimeoutError as exc:
128
+ raise ReadTimeout(str(exc)) from None
129
+ except OSError as exc:
130
+ raise ReadError(str(exc)) from None
131
+
132
+ def write(self, data: bytes, *, timeout: float | None = None) -> None:
133
+ self._sock.settimeout(timeout)
134
+ view = memoryview(data)
135
+ try:
136
+ while view:
137
+ sent = self._sock.send(view)
138
+ view = view[sent:]
139
+ except TimeoutError as exc:
140
+ raise WriteTimeout(str(exc)) from None
141
+ except OSError as exc:
142
+ raise WriteError(str(exc)) from None
143
+
144
+ def close(self) -> None:
145
+ self._sock.close()
146
+
147
+ def is_readable(self) -> bool:
148
+ return True
149
+
150
+
151
+ class AsyncUDPBackend:
152
+ async def connect(self, host: str, port: int, *, timeout: float | None = None) -> AsyncUDPSocket:
153
+ try:
154
+ with anyio.fail_after(timeout):
155
+ udp_socket = await anyio.create_connected_udp_socket(remote_host=host, remote_port=port)
156
+ return AsyncUDPSocket(udp_socket)
157
+ except TimeoutError as exc:
158
+ raise ConnectTimeout(str(exc)) from None
159
+ except OSError as exc:
160
+ raise ConnectError(str(exc)) from None
161
+
162
+
163
+ class AsyncUDPSocket:
164
+ def __init__(self, socket: anyio.abc.ConnectedUDPSocket) -> None:
165
+ self._socket = socket
166
+
167
+ async def send(self, data: bytes) -> None:
168
+ try:
169
+ await self._socket.send(item=data)
170
+ except OSError as exc:
171
+ raise WriteError(str(exc)) from None
172
+
173
+ async def receive(self, *, timeout: float | None = None) -> bytes:
174
+ try:
175
+ with anyio.fail_after(timeout):
176
+ return await self._socket.receive()
177
+ except TimeoutError as exc:
178
+ raise ReadTimeout(str(exc)) from None
179
+ except anyio.ClosedResourceError as exc:
180
+ raise ReadError(str(exc)) from None
181
+
182
+ async def aclose(self) -> None:
183
+ await self._socket.aclose()
184
+
185
+
186
+ class SyncUDPBackend:
187
+ def connect(self, host: str, port: int, *, timeout: float | None = None) -> SyncUDPSocket:
188
+ try:
189
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
190
+ sock.settimeout(timeout)
191
+ sock.connect((host, port))
192
+ sock.settimeout(None)
193
+ return SyncUDPSocket(sock)
194
+ except TimeoutError as exc:
195
+ raise ConnectTimeout(str(exc)) from None
196
+ except OSError as exc:
197
+ raise ConnectError(str(exc)) from None
198
+
199
+
200
+ class SyncUDPSocket:
201
+ def __init__(self, sock: socket.socket) -> None:
202
+ self._sock = sock
203
+
204
+ def send(self, data: bytes) -> None:
205
+ try:
206
+ self._sock.send(data)
207
+ except OSError as exc:
208
+ raise WriteError(str(exc)) from None
209
+
210
+ def receive(self, *, timeout: float | None = None) -> bytes:
211
+ self._sock.settimeout(timeout)
212
+ try:
213
+ data = self._sock.recv(65536)
214
+ return data
215
+ except TimeoutError as exc:
216
+ raise ReadTimeout(str(exc)) from None
217
+ except OSError as exc:
218
+ raise ReadError(str(exc)) from None
219
+
220
+ def close(self) -> None:
221
+ self._sock.close()
@@ -0,0 +1,259 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import time
5
+
6
+ import httpdex_parse
7
+
8
+ from httpdex_core._backends import AsyncStream, SyncStream
9
+ from httpdex_core._models import AsyncResponseStream, Origin, Request, Response, SyncResponseStream
10
+
11
+
12
+ class ConnectionStatus(enum.Enum):
13
+ NEW = enum.auto()
14
+ ACTIVE = enum.auto()
15
+ IDLE = enum.auto()
16
+ CLOSED = enum.auto()
17
+
18
+
19
+ class AsyncHTTP11Connection:
20
+ def __init__(self, origin: Origin, stream: AsyncStream, *, keepalive_expiry: float | None = 5.0) -> None:
21
+ self._origin = origin
22
+ self._stream = stream
23
+ self._h11 = httpdex_parse.Connection(httpdex_parse.CLIENT)
24
+ self._status = ConnectionStatus.IDLE
25
+ self._expire_at: float | None = None
26
+ self._keepalive_expiry = keepalive_expiry
27
+ self._timeout: float | None = None
28
+
29
+ @property
30
+ def origin(self) -> Origin:
31
+ return self._origin
32
+
33
+ @property
34
+ def status(self) -> ConnectionStatus:
35
+ return self._status
36
+
37
+ def is_idle(self) -> bool:
38
+ return self._status is ConnectionStatus.IDLE
39
+
40
+ def is_closed(self) -> bool:
41
+ return self._status is ConnectionStatus.CLOSED
42
+
43
+ def is_available(self) -> bool:
44
+ return self._status is ConnectionStatus.IDLE
45
+
46
+ def has_expired(self) -> bool:
47
+ if self._expire_at is None:
48
+ return False
49
+ now = time.monotonic()
50
+ return now > self._expire_at
51
+
52
+ async def handle_async_request(
53
+ self,
54
+ request: Request,
55
+ *,
56
+ timeout: float | None = None,
57
+ ) -> tuple[Response, AsyncResponseStream]:
58
+ self._status = ConnectionStatus.ACTIVE
59
+ self._timeout = timeout
60
+ try:
61
+ return await self._send_request_and_read_response(request)
62
+ except BaseException:
63
+ self._status = ConnectionStatus.CLOSED
64
+ await self._stream.aclose()
65
+ raise
66
+
67
+ async def _send_request_and_read_response(self, request: Request) -> tuple[Response, AsyncResponseStream]:
68
+ # Send request.
69
+ h11_request = httpdex_parse.Request(
70
+ method=request.method,
71
+ target=request.url.target,
72
+ headers=request.headers,
73
+ )
74
+ await self._stream.write(self._h11.send(h11_request), timeout=self._timeout)
75
+
76
+ if request.stream is not None:
77
+ async for chunk in request.stream: # type: ignore[union-attr] # pragma: no branch
78
+ body_data = self._h11.send(httpdex_parse.Data(data=chunk))
79
+ if body_data:
80
+ await self._stream.write(body_data, timeout=self._timeout)
81
+
82
+ eom_data = self._h11.send(httpdex_parse.EndOfMessage())
83
+ if eom_data:
84
+ await self._stream.write(eom_data, timeout=self._timeout)
85
+
86
+ # Read response.
87
+ response_event = await self._read_until_event(
88
+ (httpdex_parse.Response, httpdex_parse.InformationalResponse),
89
+ )
90
+ while isinstance(response_event, httpdex_parse.InformationalResponse):
91
+ response_event = await self._read_until_event(
92
+ (httpdex_parse.Response, httpdex_parse.InformationalResponse),
93
+ )
94
+ assert isinstance(response_event, httpdex_parse.Response)
95
+
96
+ # Build a lazy stream that reads body chunks on demand.
97
+ async def read_next_chunk() -> bytes | None:
98
+ event = await self._read_until_event((httpdex_parse.Data, httpdex_parse.EndOfMessage))
99
+ if isinstance(event, httpdex_parse.Data):
100
+ return event.data
101
+ return None # EndOfMessage
102
+
103
+ body_stream = AsyncResponseStream(read_next_chunk, self._mark_idle)
104
+
105
+ response_headers: list[tuple[bytes, bytes]] = [(rn, v) for rn, _, v in response_event.headers]
106
+ return Response(
107
+ status=response_event.status_code,
108
+ headers=response_headers,
109
+ stream=None,
110
+ ), body_stream
111
+
112
+ async def _read_until_event(
113
+ self,
114
+ event_types: tuple[type, ...],
115
+ ) -> httpdex_parse.Request | httpdex_parse.Response | httpdex_parse.InformationalResponse | httpdex_parse.Data | httpdex_parse.EndOfMessage:
116
+ while True:
117
+ event = self._h11.next_event()
118
+ if isinstance(event, event_types):
119
+ return event
120
+ if event is httpdex_parse.NEED_DATA:
121
+ data = await self._stream.read(65536, timeout=self._timeout)
122
+ self._h11.receive_data(data if data else b"")
123
+ continue
124
+ if event is httpdex_parse.PAUSED: # pragma: no cover
125
+ break
126
+ raise RuntimeError("unexpected parser state") # pragma: no cover
127
+
128
+ def _mark_idle(self) -> None:
129
+ try:
130
+ self._h11.start_next_cycle()
131
+ self._status = ConnectionStatus.IDLE
132
+ if self._keepalive_expiry is not None:
133
+ self._expire_at = time.monotonic() + self._keepalive_expiry
134
+ except httpdex_parse.LocalProtocolError:
135
+ self._status = ConnectionStatus.CLOSED
136
+
137
+ async def aclose(self) -> None:
138
+ self._status = ConnectionStatus.CLOSED
139
+ await self._stream.aclose()
140
+
141
+
142
+ class SyncHTTP11Connection:
143
+ def __init__(self, origin: Origin, stream: SyncStream, *, keepalive_expiry: float | None = 5.0) -> None:
144
+ self._origin = origin
145
+ self._stream = stream
146
+ self._h11 = httpdex_parse.Connection(httpdex_parse.CLIENT)
147
+ self._status = ConnectionStatus.IDLE
148
+ self._expire_at: float | None = None
149
+ self._keepalive_expiry = keepalive_expiry
150
+ self._timeout: float | None = None
151
+
152
+ @property
153
+ def origin(self) -> Origin:
154
+ return self._origin
155
+
156
+ @property
157
+ def status(self) -> ConnectionStatus:
158
+ return self._status
159
+
160
+ def is_idle(self) -> bool:
161
+ return self._status is ConnectionStatus.IDLE
162
+
163
+ def is_closed(self) -> bool:
164
+ return self._status is ConnectionStatus.CLOSED
165
+
166
+ def is_available(self) -> bool:
167
+ return self._status is ConnectionStatus.IDLE
168
+
169
+ def has_expired(self) -> bool:
170
+ if self._expire_at is None:
171
+ return False
172
+ now = time.monotonic()
173
+ return now > self._expire_at
174
+
175
+ def handle_request(
176
+ self,
177
+ request: Request,
178
+ *,
179
+ timeout: float | None = None,
180
+ ) -> tuple[Response, SyncResponseStream]:
181
+ self._status = ConnectionStatus.ACTIVE
182
+ self._timeout = timeout
183
+ try:
184
+ return self._send_request_and_read_response(request)
185
+ except BaseException:
186
+ self._status = ConnectionStatus.CLOSED
187
+ self._stream.close()
188
+ raise
189
+
190
+ def _send_request_and_read_response(self, request: Request) -> tuple[Response, SyncResponseStream]:
191
+ h11_request = httpdex_parse.Request(
192
+ method=request.method,
193
+ target=request.url.target,
194
+ headers=request.headers,
195
+ )
196
+ self._stream.write(self._h11.send(h11_request), timeout=self._timeout)
197
+
198
+ if request.stream is not None:
199
+ for chunk in request.stream: # type: ignore[union-attr]
200
+ body_data = self._h11.send(httpdex_parse.Data(data=chunk))
201
+ if body_data:
202
+ self._stream.write(body_data, timeout=self._timeout)
203
+
204
+ eom_data = self._h11.send(httpdex_parse.EndOfMessage())
205
+ if eom_data:
206
+ self._stream.write(eom_data, timeout=self._timeout)
207
+
208
+ response_event = self._read_until_event(
209
+ (httpdex_parse.Response, httpdex_parse.InformationalResponse),
210
+ )
211
+ while isinstance(response_event, httpdex_parse.InformationalResponse):
212
+ response_event = self._read_until_event(
213
+ (httpdex_parse.Response, httpdex_parse.InformationalResponse),
214
+ )
215
+ assert isinstance(response_event, httpdex_parse.Response)
216
+
217
+ # Build a lazy stream that reads body chunks on demand.
218
+ def read_next_chunk() -> bytes | None:
219
+ event = self._read_until_event((httpdex_parse.Data, httpdex_parse.EndOfMessage))
220
+ if isinstance(event, httpdex_parse.Data):
221
+ return event.data
222
+ return None # EndOfMessage
223
+
224
+ body_stream = SyncResponseStream(read_next_chunk, self._mark_idle)
225
+
226
+ response_headers: list[tuple[bytes, bytes]] = [(rn, v) for rn, _, v in response_event.headers]
227
+ return Response(
228
+ status=response_event.status_code,
229
+ headers=response_headers,
230
+ ), body_stream
231
+
232
+ def _read_until_event(
233
+ self,
234
+ event_types: tuple[type, ...],
235
+ ) -> httpdex_parse.Request | httpdex_parse.Response | httpdex_parse.InformationalResponse | httpdex_parse.Data | httpdex_parse.EndOfMessage:
236
+ while True:
237
+ event = self._h11.next_event()
238
+ if isinstance(event, event_types):
239
+ return event
240
+ if event is httpdex_parse.NEED_DATA:
241
+ data = self._stream.read(65536, timeout=self._timeout)
242
+ self._h11.receive_data(data if data else b"")
243
+ continue
244
+ if event is httpdex_parse.PAUSED: # pragma: no cover
245
+ break
246
+ raise RuntimeError("unexpected parser state") # pragma: no cover
247
+
248
+ def _mark_idle(self) -> None:
249
+ try:
250
+ self._h11.start_next_cycle()
251
+ self._status = ConnectionStatus.IDLE
252
+ if self._keepalive_expiry is not None:
253
+ self._expire_at = time.monotonic() + self._keepalive_expiry
254
+ except httpdex_parse.LocalProtocolError:
255
+ self._status = ConnectionStatus.CLOSED
256
+
257
+ def close(self) -> None:
258
+ self._status = ConnectionStatus.CLOSED
259
+ self._stream.close()