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.
Files changed (133) hide show
  1. {httpunk-0.1.0a2 → httpunk-0.1.2}/Cargo.lock +11 -11
  2. {httpunk-0.1.0a2 → httpunk-0.1.2}/Cargo.toml +1 -1
  3. {httpunk-0.1.0a2 → httpunk-0.1.2}/PKG-INFO +9 -2
  4. {httpunk-0.1.0a2 → httpunk-0.1.2}/README.md +7 -0
  5. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_backend/asyncio.py +21 -0
  6. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_backend/tonio.py +7 -2
  7. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_httpunk.pyi +5 -0
  8. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/server.py +66 -26
  9. {httpunk-0.1.0a2 → httpunk-0.1.2}/pyproject.toml +1 -1
  10. {httpunk-0.1.0a2 → httpunk-0.1.2}/src/http/mod.rs +16 -0
  11. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h1_server.py +66 -0
  12. httpunk-0.1.2/tests/test_headermap.py +21 -0
  13. {httpunk-0.1.0a2 → httpunk-0.1.2}/LICENSE +0 -0
  14. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/Cargo.toml +0 -0
  15. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/LICENSE +0 -0
  16. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/UPSTREAM_VERSION +0 -0
  17. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/codec/error.rs +0 -0
  18. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/codec/mod.rs +0 -0
  19. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/ext.rs +0 -0
  20. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/data.rs +0 -0
  21. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/go_away.rs +0 -0
  22. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/head.rs +0 -0
  23. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/headers.rs +0 -0
  24. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/mod.rs +0 -0
  25. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/ping.rs +0 -0
  26. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/priority.rs +0 -0
  27. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/reason.rs +0 -0
  28. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/reset.rs +0 -0
  29. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/settings.rs +0 -0
  30. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/stream_id.rs +0 -0
  31. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/util.rs +0 -0
  32. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/frame/window_update.rs +0 -0
  33. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/decoder.rs +0 -0
  34. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/encoder.rs +0 -0
  35. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/header.rs +0 -0
  36. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/huffman/mod.rs +0 -0
  37. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/huffman/table.rs +0 -0
  38. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/mod.rs +0 -0
  39. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/hpack/table.rs +0 -0
  40. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/lib.rs +0 -0
  41. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/error.rs +0 -0
  42. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/mod.rs +0 -0
  43. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/streams/flow_control.rs +0 -0
  44. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/streams/mod.rs +0 -0
  45. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-h2/src/proto/streams/state.rs +0 -0
  46. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/Cargo.toml +0 -0
  47. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/LICENSE +0 -0
  48. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/UPSTREAM_VERSION +0 -0
  49. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/body/length.rs +0 -0
  50. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/body/mod.rs +0 -0
  51. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/cfg.rs +0 -0
  52. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/common/date.rs +0 -0
  53. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/common/mod.rs +0 -0
  54. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/error.rs +0 -0
  55. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/ext/h1_reason_phrase.rs +0 -0
  56. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/ext/informational.rs +0 -0
  57. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/ext/mod.rs +0 -0
  58. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/headers.rs +0 -0
  59. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/lib.rs +0 -0
  60. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/decode.rs +0 -0
  61. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/encode.rs +0 -0
  62. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/httpunk.rs +0 -0
  63. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/io.rs +0 -0
  64. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/mod.rs +0 -0
  65. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/h1/role.rs +0 -0
  66. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/proto/mod.rs +0 -0
  67. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper/src/trace.rs +0 -0
  68. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/Cargo.toml +0 -0
  69. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/LICENSE +0 -0
  70. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/UPSTREAM_VERSION +0 -0
  71. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/src/client/mod.rs +0 -0
  72. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/src/client/proxy/matcher.rs +0 -0
  73. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/src/client/proxy/mod.rs +0 -0
  74. {httpunk-0.1.0a2 → httpunk-0.1.2}/crates/vendor-hyper-util/src/lib.rs +0 -0
  75. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/__init__.py +0 -0
  76. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_backend/__init__.py +0 -0
  77. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/_common.py +0 -0
  78. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/asyncio.py +0 -0
  79. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/exceptions.py +0 -0
  80. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/__init__.py +0 -0
  81. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/client.py +0 -0
  82. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/connection.py +0 -0
  83. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h1/share.py +0 -0
  84. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/__init__.py +0 -0
  85. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/client.py +0 -0
  86. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/connection.py +0 -0
  87. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/server.py +0 -0
  88. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/settings.py +0 -0
  89. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/share.py +0 -0
  90. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/stream.py +0 -0
  91. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/h2/streams.py +0 -0
  92. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/http.py +0 -0
  93. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/py.typed +0 -0
  94. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/types.py +0 -0
  95. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/__init__.py +0 -0
  96. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/auto.py +0 -0
  97. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/client.py +0 -0
  98. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/graceful.py +0 -0
  99. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/pool.py +0 -0
  100. {httpunk-0.1.0a2 → httpunk-0.1.2}/httpunk/util/proxy.py +0 -0
  101. {httpunk-0.1.0a2 → httpunk-0.1.2}/src/errors.rs +0 -0
  102. {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h1/codec.rs +0 -0
  103. {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h1/mod.rs +0 -0
  104. {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h2/codec.rs +0 -0
  105. {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h2/mod.rs +0 -0
  106. {httpunk-0.1.0a2 → httpunk-0.1.2}/src/h2/streams.rs +0 -0
  107. {httpunk-0.1.0a2 → httpunk-0.1.2}/src/lib.rs +0 -0
  108. {httpunk-0.1.0a2 → httpunk-0.1.2}/src/proxy.rs +0 -0
  109. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/_client.py +0 -0
  110. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/conftest.py +0 -0
  111. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_asyncio_backend.py +0 -0
  112. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_asyncio_client_protocol.py +0 -0
  113. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_asyncio_e2e.py +0 -0
  114. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_asyncio_protocol.py +0 -0
  115. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_backend.py +0 -0
  116. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h1_codec.py +0 -0
  117. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h1_conn.py +0 -0
  118. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h1_decode.py +0 -0
  119. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_codec.py +0 -0
  120. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_conn.py +0 -0
  121. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_errors.py +0 -0
  122. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_flow.py +0 -0
  123. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_flow_control.py +0 -0
  124. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_ready.py +0 -0
  125. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_server.py +0 -0
  126. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_settings.py +0 -0
  127. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_h2_state.py +0 -0
  128. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_auto.py +0 -0
  129. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_connect.py +0 -0
  130. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_graceful.py +0 -0
  131. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_pool.py +0 -0
  132. {httpunk-0.1.0a2 → httpunk-0.1.2}/tests/test_util_proxy.py +0 -0
  133. {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.11.1"
19
+ version = "1.12.1"
20
20
  source = "registry+https://github.com/rust-lang/crates.io-index"
21
- checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
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.0"
89
+ version = "1.4.2"
90
90
  source = "registry+https://github.com/rust-lang/crates.io-index"
91
- checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
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.0-alpha2"
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.183"
178
+ version = "0.2.186"
179
179
  source = "registry+https://github.com/rust-lang/crates.io-index"
180
- checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
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.45"
274
+ version = "1.0.46"
275
275
  source = "registry+https://github.com/rust-lang/crates.io-index"
276
- checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
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.117"
295
+ version = "2.0.118"
296
296
  source = "registry+https://github.com/rust-lang/crates.io-index"
297
- checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
297
+ checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
298
298
  dependencies = [
299
299
  "proc-macro2",
300
300
  "quote",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "httpunk"
3
- version = "0.1.0-alpha2"
3
+ version = "0.1.2"
4
4
  description = "The Rust HTTP library for Python"
5
5
  authors = [
6
6
  "Giovanni Barillari <g@baro.dev>",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: httpunk
3
- Version: 0.1.0a2
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.7.1 ; python_full_version >= '3.14' and extra == 'tonio'
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), for deadline races (`select`)
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
- # http1.rs L249): race the read against a deadline; if it wins, close with no
319
- # response (hyper closes on a header-read timeout). `select` cancels the losing
320
- # branch, cleanly dropping the parked read. `None` disables the timeout.
321
- # NIT (design cleanup, deferred): this `sleep` + `select` deadline could become a
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
- async def _deadline():
328
- await self.backend.sleep(self._header_read_timeout)
329
- return _TIMED_OUT
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: race the read against a graceful-shutdown signal
356
- # so an idle keep-alive connection completes promptly (tonio can't
357
- # wake a parked recv from another task; hyper's poll re-checks
358
- # `should_read` and completes instead of reading).
359
- data = await self._read_or_shutdown(_READ_SIZE)
360
- if data is _SHUTDOWN:
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
- async def _read_or_shutdown(self, n):
387
- # Read the next head byte(s), or `_SHUTDOWN` if `graceful_shutdown()` fires
388
- # first. The backend's `select` cancels the losing branch, so the parked read
389
- # is cleanly dropped when shutdown wins.
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 await self.backend.select(_recv(), _await_shutdown())
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`),
@@ -36,7 +36,7 @@ requires-python = ">=3.10"
36
36
  dependencies = []
37
37
 
38
38
  [project.optional-dependencies]
39
- tonio = ["tonio~=0.7.1; python_version >= '3.14'"]
39
+ tonio = ["tonio~=0.8.1; python_version >= '3.14'"]
40
40
 
41
41
  [dependency-groups]
42
42
  build = [
@@ -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