httpunk 0.1.0a2__tar.gz → 0.1.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.
- {httpunk-0.1.0a2 → httpunk-0.1.2}/Cargo.lock +11 -11
- {httpunk-0.1.0a2 → httpunk-0.1.2}/Cargo.toml +1 -1
- {httpunk-0.1.0a2 → httpunk-0.1.2}/PKG-INFO +9 -2
- {httpunk-0.1.0a2 → httpunk-0.1.2}/README.md +7 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_backend/asyncio.py +21 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_backend/tonio.py +7 -2
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_httpunk.pyi +5 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/server.py +66 -26
- {httpunk-0.1.0a2 → httpunk-0.1.2}/pyproject.toml +1 -1
- {httpunk-0.1.0a2 → httpunk-0.1.2}/src/http/mod.rs +16 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h1_server.py +66 -0
- httpunk-0.1.2/tests/test_headermap.py +21 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/LICENSE +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/Cargo.toml +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/LICENSE +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/codec/error.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/codec/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/ext.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/data.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/go_away.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/head.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/headers.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/ping.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/priority.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/reason.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/reset.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/settings.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/util.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/window_update.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/header.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/table.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/lib.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/error.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/Cargo.toml +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/LICENSE +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/body/length.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/body/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/cfg.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/common/date.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/common/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/error.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/ext/informational.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/ext/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/headers.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/lib.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/trace.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/Cargo.toml +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/LICENSE +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/src/lib.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/__init__.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_backend/__init__.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_common.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/asyncio.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/exceptions.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/__init__.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/client.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/connection.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/share.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/__init__.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/client.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/connection.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/server.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/settings.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/share.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/stream.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/streams.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/http.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/py.typed +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/types.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/__init__.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/auto.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/client.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/graceful.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/pool.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/proxy.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/src/errors.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h1/codec.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h1/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h2/codec.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h2/mod.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h2/streams.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/src/lib.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/src/proxy.rs +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/_client.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/conftest.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_asyncio_backend.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_asyncio_client_protocol.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_asyncio_e2e.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_asyncio_protocol.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_backend.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h1_codec.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h1_conn.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h1_decode.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_codec.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_conn.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_errors.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_flow.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_flow_control.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_ready.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_server.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_settings.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_state.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_auto.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_connect.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_graceful.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_pool.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_proxy.py +0 -0
- {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_tls.py +0 -0
|
@@ -16,9 +16,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
|
|
16
16
|
|
|
17
17
|
[[package]]
|
|
18
18
|
name = "bytes"
|
|
19
|
-
version = "1.
|
|
19
|
+
version = "1.12.1"
|
|
20
20
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
21
|
-
checksum = "
|
|
21
|
+
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|
22
22
|
|
|
23
23
|
[[package]]
|
|
24
24
|
name = "equivalent"
|
|
@@ -86,9 +86,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
|
86
86
|
|
|
87
87
|
[[package]]
|
|
88
88
|
name = "http"
|
|
89
|
-
version = "1.4.
|
|
89
|
+
version = "1.4.2"
|
|
90
90
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
91
|
-
checksum = "
|
|
91
|
+
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
|
92
92
|
dependencies = [
|
|
93
93
|
"bytes",
|
|
94
94
|
"itoa",
|
|
@@ -118,7 +118,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
|
|
118
118
|
|
|
119
119
|
[[package]]
|
|
120
120
|
name = "httpunk"
|
|
121
|
-
version = "0.1.
|
|
121
|
+
version = "0.1.2"
|
|
122
122
|
dependencies = [
|
|
123
123
|
"bytes",
|
|
124
124
|
"h2",
|
|
@@ -175,9 +175,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
|
|
175
175
|
|
|
176
176
|
[[package]]
|
|
177
177
|
name = "libc"
|
|
178
|
-
version = "0.2.
|
|
178
|
+
version = "0.2.186"
|
|
179
179
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
180
|
-
checksum = "
|
|
180
|
+
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
|
181
181
|
|
|
182
182
|
[[package]]
|
|
183
183
|
name = "once_cell"
|
|
@@ -271,9 +271,9 @@ dependencies = [
|
|
|
271
271
|
|
|
272
272
|
[[package]]
|
|
273
273
|
name = "quote"
|
|
274
|
-
version = "1.0.
|
|
274
|
+
version = "1.0.46"
|
|
275
275
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
276
|
-
checksum = "
|
|
276
|
+
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
|
277
277
|
dependencies = [
|
|
278
278
|
"proc-macro2",
|
|
279
279
|
]
|
|
@@ -292,9 +292,9 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
|
|
292
292
|
|
|
293
293
|
[[package]]
|
|
294
294
|
name = "syn"
|
|
295
|
-
version = "2.0.
|
|
295
|
+
version = "2.0.118"
|
|
296
296
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
297
|
-
checksum = "
|
|
297
|
+
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
|
298
298
|
dependencies = [
|
|
299
299
|
"proc-macro2",
|
|
300
300
|
"quote",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: httpunk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Classifier: Development Status :: 3 - Alpha
|
|
5
5
|
Classifier: Intended Audience :: Developers
|
|
6
6
|
Classifier: License :: OSI Approved :: BSD License
|
|
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
|
19
19
|
Classifier: Programming Language :: Python
|
|
20
20
|
Classifier: Programming Language :: Rust
|
|
21
21
|
Classifier: Topic :: Internet :: WWW/HTTP
|
|
22
|
-
Requires-Dist: tonio~=0.
|
|
22
|
+
Requires-Dist: tonio~=0.8.1 ; python_full_version >= '3.14' and extra == 'tonio'
|
|
23
23
|
Provides-Extra: tonio
|
|
24
24
|
License-File: LICENSE
|
|
25
25
|
Summary: The Rust HTTP library for Python
|
|
@@ -265,6 +265,13 @@ h.get_all("set-cookie") # [b'a=1', b'b=2']
|
|
|
265
265
|
Anywhere a `headers=` argument is accepted you can pass a `HeaderMap`, a mapping, or an
|
|
266
266
|
iterable of `(name, value)` pairs.
|
|
267
267
|
|
|
268
|
+
For consumers that want raw pairs — e.g. an ASGI server building a scope's `headers` —
|
|
269
|
+
`raw_items()` returns `(bytes, bytes)` tuples (names already lowercase) in one call:
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
h.raw_items() # [(b'content-type', b'text/plain'), (b'set-cookie', b'a=1'), ...]
|
|
273
|
+
```
|
|
274
|
+
|
|
268
275
|
### Errors
|
|
269
276
|
|
|
270
277
|
httpunk's exceptions all derive from a common `HTTPunkError` root. `ConnectionClosedError`
|
|
@@ -230,6 +230,13 @@ h.get_all("set-cookie") # [b'a=1', b'b=2']
|
|
|
230
230
|
Anywhere a `headers=` argument is accepted you can pass a `HeaderMap`, a mapping, or an
|
|
231
231
|
iterable of `(name, value)` pairs.
|
|
232
232
|
|
|
233
|
+
For consumers that want raw pairs — e.g. an ASGI server building a scope's `headers` —
|
|
234
|
+
`raw_items()` returns `(bytes, bytes)` tuples (names already lowercase) in one call:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
h.raw_items() # [(b'content-type', b'text/plain'), (b'set-cookie', b'a=1'), ...]
|
|
238
|
+
```
|
|
239
|
+
|
|
233
240
|
### Errors
|
|
234
241
|
|
|
235
242
|
httpunk's exceptions all derive from a common `HTTPunkError` root. `ConnectionClosedError`
|
|
@@ -162,6 +162,12 @@ class _AsyncioStream(asyncio.Protocol):
|
|
|
162
162
|
if waiter is not None and not waiter.done():
|
|
163
163
|
waiter.set_result(None)
|
|
164
164
|
|
|
165
|
+
def interrupt_read(self):
|
|
166
|
+
"""Wake a parked `receive_some` so it returns b"" — lets a graceful shutdown release
|
|
167
|
+
an idle keep-alive read WITHOUT racing a coroutine (asyncio can wake a parked read from
|
|
168
|
+
another task; tonio can't, which is why it uses a select-based race instead)."""
|
|
169
|
+
self._wake_reader()
|
|
170
|
+
|
|
165
171
|
|
|
166
172
|
class _AsyncioScope:
|
|
167
173
|
"""A nursery over `asyncio` tasks, matching tonio's scope surface: `spawn`,
|
|
@@ -297,3 +303,18 @@ class AsyncioBackend:
|
|
|
297
303
|
scope = _AsyncioScope
|
|
298
304
|
monotonic = staticmethod(_time.monotonic)
|
|
299
305
|
sleep = staticmethod(asyncio.sleep) # async sleep(seconds), for deadline races (`select`)
|
|
306
|
+
|
|
307
|
+
# Every backend implements `timeout` (above), so the h1 server always uses it for the
|
|
308
|
+
# head-read deadline. This extra capability is asyncio-only: `_AsyncioStream.interrupt_read`
|
|
309
|
+
# can wake a parked idle read from another task, so the h1 server can skip the per-read
|
|
310
|
+
# shutdown `select`. tonio can't (no such method) → it keeps the select there.
|
|
311
|
+
native_read_interrupt = True
|
|
312
|
+
|
|
313
|
+
async def timeout(self, coro, seconds):
|
|
314
|
+
"""Run `coro` bounded by `seconds`; return `(result, True)` on completion, or
|
|
315
|
+
`(None, False)` on timeout (the coro is cancelled). One task + one timer, far cheaper
|
|
316
|
+
than racing a `sleep()` coroutine via `select`."""
|
|
317
|
+
try:
|
|
318
|
+
return await asyncio.wait_for(coro, seconds), True
|
|
319
|
+
except (TimeoutError, asyncio.TimeoutError):
|
|
320
|
+
return None, False
|
|
@@ -12,7 +12,7 @@ from tonio.colored.net import open_tcp_stream as _open_tcp_stream
|
|
|
12
12
|
from tonio.colored.net.tls import open_tls_over_tcp_stream as _open_tls_over_tcp_stream
|
|
13
13
|
from tonio.colored.sync import Lock as _Lock, Semaphore as _Semaphore
|
|
14
14
|
from tonio.colored.sync.channel import unbounded as _unbounded
|
|
15
|
-
from tonio.colored.time import time as _now
|
|
15
|
+
from tonio.colored.time import time as _now, timeout as _timeout
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class _TonioSemaphore:
|
|
@@ -107,4 +107,9 @@ class TonioBackend:
|
|
|
107
107
|
semaphore = _TonioSemaphore
|
|
108
108
|
queue = staticmethod(_unbounded)
|
|
109
109
|
monotonic = staticmethod(_now)
|
|
110
|
-
sleep = staticmethod(_colored.sleep) # async sleep(seconds)
|
|
110
|
+
sleep = staticmethod(_colored.sleep) # async sleep(seconds)
|
|
111
|
+
# `timeout(coro, seconds) -> (result, completed)` — tonio's native deadline; the h1 server
|
|
112
|
+
# uses it to bound the request-head read (measured ~12-16% faster than racing sleep() via
|
|
113
|
+
# select). Same contract as AsyncioBackend.timeout. tonio still can't wake a parked recv from
|
|
114
|
+
# another task, so it does NOT advertise `native_read_interrupt` (idle shutdown stays select).
|
|
115
|
+
timeout = staticmethod(_timeout)
|
|
@@ -88,6 +88,11 @@ class HeaderMap:
|
|
|
88
88
|
def items(self) -> list[tuple[str, bytes]]:
|
|
89
89
|
"""Every `(name, value)` pair, in order (duplicates included)."""
|
|
90
90
|
|
|
91
|
+
def raw_items(self) -> list[tuple[bytes, bytes]]:
|
|
92
|
+
"""Every `(name, value)` pair with the name as raw `bytes` (already lowercase
|
|
93
|
+
ASCII), in order, duplicates included — the exact shape ASGI servers want for a
|
|
94
|
+
scope's `headers`, in one boundary crossing with no per-name re-encoding."""
|
|
95
|
+
|
|
91
96
|
def __iter__(self) -> Iterator[str]: ...
|
|
92
97
|
def __len__(self) -> int: ...
|
|
93
98
|
def __eq__(self, other: object) -> bool: ...
|
|
@@ -53,7 +53,6 @@ _CONTINUE = b"HTTP/1.1 100 Continue\r\n\r\n"
|
|
|
53
53
|
# (io.rs: 8192 + 4096*100). Bounds per-connection memory against a slow/oversized head.
|
|
54
54
|
_MAX_HEAD_SIZE = 8192 + 4096 * 100
|
|
55
55
|
_DEFAULT_HEADER_READ_TIMEOUT = 30.0 # hyper's `header_read_timeout` default (http1.rs L249)
|
|
56
|
-
_TIMED_OUT = object() # sentinel: the header read exceeded `header_read_timeout`
|
|
57
56
|
# The HTTP/2 connection preface (client prior-knowledge). An h1 server that sees it
|
|
58
57
|
# closes silently with a version error rather than writing a 400 (hyper `on_parse_error`
|
|
59
58
|
# -> `has_h2_prefix` -> `new_version_h2`, conn.rs L29/L809-812).
|
|
@@ -174,6 +173,22 @@ class ServerRequest:
|
|
|
174
173
|
self._responded = True
|
|
175
174
|
await self._conn.send_response(self, status, headers, body)
|
|
176
175
|
|
|
176
|
+
def detach(self) -> bytes:
|
|
177
|
+
"""Take over the raw connection for a protocol upgrade (WebSocket, or a custom protocol):
|
|
178
|
+
stop the server's accept loop and relinquish the transport WITHOUT closing it, returning
|
|
179
|
+
any bytes already read past the request head (to replay). The caller — which supplied the
|
|
180
|
+
transport to `H1Server` — owns it afterwards and must drive the upgrade itself (httpunk
|
|
181
|
+
sends no response). Like httpunk's 101/CONNECT tunnel hand-off, but caller-driven.
|
|
182
|
+
|
|
183
|
+
After this, the accept loop ends (`next_request` returns None) and the server's close on
|
|
184
|
+
`__aexit__` is a no-op, so the transport stays open. cf. Go's `http.Hijacker`."""
|
|
185
|
+
if self._responded:
|
|
186
|
+
raise RuntimeError("cannot detach: a response was already sent for this request")
|
|
187
|
+
self._responded = True
|
|
188
|
+
leftover = self._decoder.take_buffered()
|
|
189
|
+
self._conn._detach()
|
|
190
|
+
return leftover
|
|
191
|
+
|
|
177
192
|
def __repr__(self) -> str:
|
|
178
193
|
return f"ServerRequest(method={self.method!r}, target={self.target!r})"
|
|
179
194
|
|
|
@@ -193,6 +208,12 @@ class ServerConnection(H1ConnectionBase):
|
|
|
193
208
|
# Max time to read a complete request head before closing (slowloris defence),
|
|
194
209
|
# hyper's `header_read_timeout` (default 30s, http1.rs L249). `None` disables it.
|
|
195
210
|
self._header_read_timeout = header_read_timeout
|
|
211
|
+
# The head-read deadline always uses `backend.timeout` (every backend implements it).
|
|
212
|
+
# This capability is extra: a backend that can wake a parked read from another task
|
|
213
|
+
# (asyncio) lets us skip the per-read shutdown `select`; absent (tonio) → use `select`.
|
|
214
|
+
# See `_read_or_shutdown` + `graceful_shutdown`.
|
|
215
|
+
self._native_read_interrupt = getattr(self.backend, "native_read_interrupt", False)
|
|
216
|
+
self._idle_read_parked = False # True only while parked in the idle between-requests read
|
|
196
217
|
|
|
197
218
|
async def start(self):
|
|
198
219
|
pass # HTTP/1 has no connection preface / handshake
|
|
@@ -206,6 +227,10 @@ class ServerConnection(H1ConnectionBase):
|
|
|
206
227
|
# loop to completion and closes; nothing is awaited/closed here.
|
|
207
228
|
self._reusable = False
|
|
208
229
|
self._shutdown_evt.set()
|
|
230
|
+
if self._native_read_interrupt and self._idle_read_parked:
|
|
231
|
+
# asyncio: wake the read parked idly between requests so it returns promptly and
|
|
232
|
+
# the connection closes, instead of relying on a per-read select race (tonio's path).
|
|
233
|
+
self.transport.interrupt_read()
|
|
209
234
|
|
|
210
235
|
def mark_unusable(self):
|
|
211
236
|
self._reusable = False
|
|
@@ -314,22 +339,17 @@ class ServerConnection(H1ConnectionBase):
|
|
|
314
339
|
return False
|
|
315
340
|
|
|
316
341
|
async def _read_request_head(self, codec, initial=b""):
|
|
317
|
-
# Bound the head read by `header_read_timeout` (slowloris defence, hyper
|
|
318
|
-
#
|
|
319
|
-
#
|
|
320
|
-
#
|
|
321
|
-
#
|
|
322
|
-
# normalized `backend.timeout(coro, seconds)` primitive — more direct, no
|
|
323
|
-
# sentinel / nested select. Kept as-is for now (see TODONOTES).
|
|
342
|
+
# Bound the head read by `header_read_timeout` (slowloris defence, hyper http1.rs L249):
|
|
343
|
+
# if the deadline wins, close with no response (hyper closes on a header-read timeout).
|
|
344
|
+
# `backend.timeout(coro, seconds) -> (result, completed)` cancels the read cleanly on
|
|
345
|
+
# expiry — cheap on every backend (asyncio: one task + one timer; tonio: its native
|
|
346
|
+
# timeout). `None` disables the deadline.
|
|
324
347
|
if self._header_read_timeout is None:
|
|
325
348
|
return await self._read_head_frames(codec, initial)
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
result = await self.backend.select(self._read_head_frames(codec, initial), _deadline())
|
|
332
|
-
if result is _TIMED_OUT:
|
|
349
|
+
result, completed = await self.backend.timeout(
|
|
350
|
+
self._read_head_frames(codec, initial), self._header_read_timeout
|
|
351
|
+
)
|
|
352
|
+
if not completed:
|
|
333
353
|
self._closed = True
|
|
334
354
|
self._reusable = False
|
|
335
355
|
self._close_transport()
|
|
@@ -352,13 +372,22 @@ class ServerConnection(H1ConnectionBase):
|
|
|
352
372
|
idle = not initial # no bytes of this request seen yet -> a graceful shutdown may end it
|
|
353
373
|
while True:
|
|
354
374
|
if idle:
|
|
355
|
-
# Between requests:
|
|
356
|
-
#
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
375
|
+
# Between requests: a graceful shutdown may end the wait (see
|
|
376
|
+
# `_read_or_shutdown` — sync; returns the sentinel or the read awaitable).
|
|
377
|
+
read = self._read_or_shutdown(_READ_SIZE)
|
|
378
|
+
if read is _SHUTDOWN:
|
|
379
|
+
return None
|
|
380
|
+
# Flag the park as idle so graceful_shutdown() may wake THIS read only —
|
|
381
|
+
# never a mid-head read (a request in flight must complete).
|
|
382
|
+
self._idle_read_parked = True
|
|
383
|
+
try:
|
|
384
|
+
data = await read
|
|
385
|
+
finally:
|
|
386
|
+
self._idle_read_parked = False
|
|
387
|
+
if data is _SHUTDOWN: # tonio select: the shutdown signal won the race
|
|
361
388
|
return None
|
|
389
|
+
# An interrupt_read shutdown wake surfaces as b"" and falls into the EOF
|
|
390
|
+
# return below — same outcome either way (loop ends; close, no response).
|
|
362
391
|
else:
|
|
363
392
|
data = await self.transport.receive_some(_READ_SIZE)
|
|
364
393
|
if not data:
|
|
@@ -383,13 +412,24 @@ class ServerConnection(H1ConnectionBase):
|
|
|
383
412
|
if buffered >= _MAX_HEAD_SIZE:
|
|
384
413
|
raise ValueError("message head is too large (Parse::TooLarge)")
|
|
385
414
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
415
|
+
def _read_or_shutdown(self, n):
|
|
416
|
+
"""The next idle-read awaitable, or `_SHUTDOWN` if a graceful shutdown was already
|
|
417
|
+
requested. A plain (sync) function so the fast path adds no wrapper coroutine: the
|
|
418
|
+
caller awaits the returned awaitable with `_idle_read_parked` set around it (so
|
|
419
|
+
`graceful_shutdown()` wakes only an idly-parked read, never a mid-head one) and
|
|
420
|
+
treats an awaited `_SHUTDOWN` — or a woken `b""` — as shutdown, not data.
|
|
421
|
+
|
|
422
|
+
asyncio (`native_read_interrupt`): a plain read; if it parks, graceful_shutdown()
|
|
423
|
+
wakes it via `interrupt_read` and the read returns b"". Backends without a native
|
|
424
|
+
read interrupt (tonio can't wake a parked recv from another task): race the read
|
|
425
|
+
against the shutdown signal via `select`, which cancels the losing branch — hyper's
|
|
426
|
+
poll instead re-checks `should_read` and completes without reading."""
|
|
390
427
|
if self._shutdown_evt.is_set():
|
|
391
428
|
return _SHUTDOWN
|
|
392
429
|
|
|
430
|
+
if self._native_read_interrupt:
|
|
431
|
+
return self.transport.receive_some(n)
|
|
432
|
+
|
|
393
433
|
def _recv():
|
|
394
434
|
return self.transport.receive_some(n)
|
|
395
435
|
|
|
@@ -397,7 +437,7 @@ class ServerConnection(H1ConnectionBase):
|
|
|
397
437
|
await self._shutdown_evt.wait()
|
|
398
438
|
return _SHUTDOWN
|
|
399
439
|
|
|
400
|
-
return
|
|
440
|
+
return self.backend.select(_recv(), _await_shutdown())
|
|
401
441
|
|
|
402
442
|
async def _send_error(self, codec, status):
|
|
403
443
|
"""Best-effort automatic error response (bodyless, `Connection: close`),
|
|
@@ -238,6 +238,22 @@ impl HeaderMap {
|
|
|
238
238
|
Ok(self.items_list(py)?.unbind())
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/// Every `(name, value)` pair with the name as raw `bytes` (already lowercase
|
|
242
|
+
/// ASCII in the `http` crate), in order, duplicates included — the exact shape
|
|
243
|
+
/// ASGI servers want for a scope's `headers`, in one boundary crossing and with
|
|
244
|
+
/// no per-name re-encoding.
|
|
245
|
+
fn raw_items(&self, py: Python<'_>) -> PyResult<Py<PyList>> {
|
|
246
|
+
let guard = self.inner.lock().unwrap();
|
|
247
|
+
let list = PyList::empty(py);
|
|
248
|
+
for (name, value) in guard.iter() {
|
|
249
|
+
list.append((
|
|
250
|
+
PyBytes::new(py, name.as_str().as_bytes()),
|
|
251
|
+
PyBytes::new(py, value.as_bytes()),
|
|
252
|
+
))?;
|
|
253
|
+
}
|
|
254
|
+
Ok(list.unbind())
|
|
255
|
+
}
|
|
256
|
+
|
|
241
257
|
fn __iter__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
|
|
242
258
|
let list = PyList::new(py, self.keys())?;
|
|
243
259
|
Ok(list.try_iter()?.into_any().unbind())
|
|
@@ -9,6 +9,7 @@ from _client import open_h1
|
|
|
9
9
|
from tonio.colored import scope
|
|
10
10
|
from tonio.colored.net import open_tcp_listeners
|
|
11
11
|
|
|
12
|
+
from httpunk._backend.asyncio import AsyncioBackend
|
|
12
13
|
from httpunk._backend.tonio import TonioBackend
|
|
13
14
|
from httpunk.exceptions import ConnectionClosedError
|
|
14
15
|
from httpunk.h1 import H1Server
|
|
@@ -77,6 +78,22 @@ class _StubTransport:
|
|
|
77
78
|
self.closed = True
|
|
78
79
|
|
|
79
80
|
|
|
81
|
+
class _PeekableStub(_StubTransport):
|
|
82
|
+
"""A stub whose SECOND payload becomes readable only after the first is consumed —
|
|
83
|
+
models a pipelined request sitting buffered in the transport while the previous
|
|
84
|
+
request is being served."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, first, buffered):
|
|
87
|
+
super().__init__(first)
|
|
88
|
+
self._buffered = buffered
|
|
89
|
+
|
|
90
|
+
async def receive_some(self, max_bytes=65536):
|
|
91
|
+
if self._data:
|
|
92
|
+
return await super().receive_some(max_bytes)
|
|
93
|
+
chunk, self._buffered = self._buffered[:max_bytes], self._buffered[max_bytes:]
|
|
94
|
+
return chunk
|
|
95
|
+
|
|
96
|
+
|
|
80
97
|
async def _echo_server(listener, seen=None):
|
|
81
98
|
"""Accept one connection; echo each request as 200 `b"<METHOD> <target> -> " + body`."""
|
|
82
99
|
transport = await listener.accept()
|
|
@@ -276,6 +293,55 @@ async def test_server_header_read_timeout_closes_slow_head():
|
|
|
276
293
|
s.cancel()
|
|
277
294
|
|
|
278
295
|
|
|
296
|
+
@pytest.mark.asyncio
|
|
297
|
+
async def test_server_shutdown_wins_over_buffered_pipelined_request():
|
|
298
|
+
"""REGRESSION GUARD: a graceful-shutdown signal is honored BEFORE any idle read consumes
|
|
299
|
+
already-buffered bytes — hyper stops parsing new heads once keep-alive is disabled
|
|
300
|
+
(`can_read_head` under KA::Disabled), so a pipelined request already sitting in the
|
|
301
|
+
transport must NOT be served. Whitebox: the shutdown event is set directly (without
|
|
302
|
+
`graceful_shutdown()`'s `_reusable = False`, which would end the accept loop before the
|
|
303
|
+
read even starts) to pin the in-loop ordering: shutdown check -> read."""
|
|
304
|
+
transport = _PeekableStub(
|
|
305
|
+
b"GET /a HTTP/1.1\r\nhost: x\r\n\r\n",
|
|
306
|
+
b"GET /b HTTP/1.1\r\nhost: x\r\n\r\n",
|
|
307
|
+
)
|
|
308
|
+
conn = ServerConnection(transport, backend=AsyncioBackend())
|
|
309
|
+
req = await conn.next_request()
|
|
310
|
+
assert req.target == "/a"
|
|
311
|
+
await req.respond(200)
|
|
312
|
+
conn._shutdown_evt.set() # signal only (whitebox) — see docstring
|
|
313
|
+
assert await conn.next_request() is None # /b is readable but must NOT be parsed
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@pytest.mark.tonio
|
|
317
|
+
async def test_server_detach_hands_off_open_connection():
|
|
318
|
+
"""detach() stops the accept loop and relinquishes the transport WITHOUT closing it,
|
|
319
|
+
returning bytes read past the request head — so a caller can take over the raw connection
|
|
320
|
+
for a protocol upgrade (e.g. WebSocket)."""
|
|
321
|
+
listener, host, port = await _listener()
|
|
322
|
+
captured = {}
|
|
323
|
+
|
|
324
|
+
async def serve():
|
|
325
|
+
transport = await listener.accept()
|
|
326
|
+
async with H1Server(transport) as server:
|
|
327
|
+
async for req in server:
|
|
328
|
+
captured["leftover"] = req.detach() # loop ends next (detached), socket stays open
|
|
329
|
+
# `__aexit__` ran (close() is a no-op after detach) — the transport is still usable, so
|
|
330
|
+
# the caller drives its own upgrade response on the raw connection.
|
|
331
|
+
await transport.send_all(b"HTTP/1.1 101 Switching Protocols\r\nupgrade: custom\r\n\r\nOWNED")
|
|
332
|
+
|
|
333
|
+
async with scope() as s:
|
|
334
|
+
s.spawn(serve())
|
|
335
|
+
client = await _raw_client(host, port)
|
|
336
|
+
# request head + trailing bytes the client sent past it (must survive as `leftover`)
|
|
337
|
+
await client.send_all(b"GET /up HTTP/1.1\r\nhost: x\r\nupgrade: custom\r\nconnection: upgrade\r\n\r\nEXTRA")
|
|
338
|
+
data = await _read_until(client, b"OWNED")
|
|
339
|
+
s.cancel()
|
|
340
|
+
|
|
341
|
+
assert captured["leftover"] == b"EXTRA" # bytes past the head handed back
|
|
342
|
+
assert b"101 Switching Protocols" in data and data.endswith(b"OWNED") # socket stayed open
|
|
343
|
+
|
|
344
|
+
|
|
279
345
|
@pytest.mark.tonio
|
|
280
346
|
async def test_server_no_100_continue_for_http10():
|
|
281
347
|
"""An HTTP/1.0 request never gets an auto 100-continue, even with `Expect:
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""HeaderMap surface tests (Rust `http` crate wrapper) — currently just `raw_items`,
|
|
2
|
+
the ASGI-shaped `(bytes, bytes)` view; the rest of the surface is exercised throughout
|
|
3
|
+
the h1/h2 suites."""
|
|
4
|
+
|
|
5
|
+
from httpunk import HeaderMap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_raw_items_matches_items_with_bytes_names():
|
|
9
|
+
h = HeaderMap({"Content-Type": "text/plain"})
|
|
10
|
+
h.add("set-cookie", "a=1")
|
|
11
|
+
h.add("Set-Cookie", "b=2") # names normalize to lowercase; duplicates kept in order
|
|
12
|
+
|
|
13
|
+
raw = h.raw_items()
|
|
14
|
+
assert raw == [(b"content-type", b"text/plain"), (b"set-cookie", b"a=1"), (b"set-cookie", b"b=2")]
|
|
15
|
+
# exact parity with items(), modulo the name type
|
|
16
|
+
assert raw == [(name.encode("latin-1"), value) for name, value in h.items()]
|
|
17
|
+
assert all(isinstance(n, bytes) and isinstance(v, bytes) for n, v in raw)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_raw_items_empty():
|
|
21
|
+
assert HeaderMap().raw_items() == []
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|