workers-runtime-sdk 1.5.4__tar.gz → 1.6.1__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 (26) hide show
  1. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/CHANGELOG.md +16 -0
  2. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/PKG-INFO +1 -1
  3. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/pyproject.toml +1 -1
  4. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/asgi.py +79 -27
  5. workers_runtime_sdk-1.6.1/src/workers/wsgi.py +372 -0
  6. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/uv.lock +1 -1
  7. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/.gitignore +0 -0
  8. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/AGENTS.md +0 -0
  9. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/README.md +0 -0
  10. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/_cloudflare_compat_flags.pyi +0 -0
  11. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/_pyodide_entrypoint_helper.pyi +0 -0
  12. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/_workers_sdk_entropy_import_context.pth +0 -0
  13. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/_workers_sdk_entropy_import_context.py +0 -0
  14. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/_workers_sdk_entropy_import_context_loader.py +0 -0
  15. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/__init__.py +0 -0
  16. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/_workers.py +0 -0
  17. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/blob.py +0 -0
  18. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/fetch.py +0 -0
  19. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/formdata.py +0 -0
  20. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/py.typed +0 -0
  21. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/request.py +0 -0
  22. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/response.py +0 -0
  23. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/rpc.py +0 -0
  24. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/types.py +0 -0
  25. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/utils.py +0 -0
  26. {workers_runtime_sdk-1.5.4 → workers_runtime_sdk-1.6.1}/src/workers/workflows.py +0 -0
@@ -2,6 +2,22 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v1.6.1 (2026-07-09)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Improve lifespan handling in asgi.py ([#148](https://github.com/cloudflare/workers-py/pull/148),
10
+ [`7c9adfc`](https://github.com/cloudflare/workers-py/commit/7c9adfce6c3997f1547901755e97c685ef6e6be3))
11
+
12
+
13
+ ## v1.6.0 (2026-07-08)
14
+
15
+ ### Features
16
+
17
+ - Add wsgi.py analogous to asgi.py ([#145](https://github.com/cloudflare/workers-py/pull/145),
18
+ [`247f9ff`](https://github.com/cloudflare/workers-py/commit/247f9ff5387eb2d286c65c1199f3ff51938578fa))
19
+
20
+
5
21
  ## v1.5.4 (2026-07-08)
6
22
 
7
23
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workers-runtime-sdk
3
- Version: 1.5.4
3
+ Version: 1.6.1
4
4
  Summary: Python SDK for Cloudflare Workers
5
5
  Project-URL: Homepage, https://github.com/cloudflare/workers-py
6
6
  Project-URL: Bug Tracker, https://github.com/cloudflare/workers-py/issues
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "workers-runtime-sdk"
7
- version = "1.5.4"
7
+ version = "1.6.1"
8
8
  description = "Python SDK for Cloudflare Workers"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,8 +1,7 @@
1
1
  import logging
2
- from asyncio import Event, Future, Queue, create_task, ensure_future, sleep
2
+ from asyncio import Event, Future, Queue, create_task, ensure_future
3
3
  from collections.abc import Awaitable
4
4
  from contextlib import contextmanager
5
- from inspect import isawaitable
6
5
  from typing import Any
7
6
 
8
7
  import js
@@ -10,6 +9,7 @@ import js
10
9
  from workers import Context, Request
11
10
 
12
11
  ASGI = {"spec_version": "2.0", "version": "3.0"}
12
+ NULL_BODY_STATUSES = frozenset({101, 103, 204, 205, 304})
13
13
  logger = logging.getLogger("asgi")
14
14
  background_tasks = set()
15
15
 
@@ -76,42 +76,86 @@ def request_to_scope(req, env, ws=False):
76
76
 
77
77
 
78
78
  async def start_application(app):
79
- shutdown_future = Future()
79
+ # Drives one ASGI lifespan startup/shutdown cycle before/after serving the
80
+ # request. The lifespan protocol is optional, so we must tolerate apps that
81
+ # don't implement it and fall back to serving requests without lifespan.
82
+ # https://asgi.readthedocs.io/en/latest/specs/lifespan.html
83
+ receive_queue = Queue()
84
+ await receive_queue.put({"type": "lifespan.startup"})
85
+
86
+ # `startup` resolves True on `lifespan.startup.complete`, False when the app
87
+ # has no lifespan support, and raises on `lifespan.startup.failed`.
88
+ # `shutdown_complete` mirrors the shutdown phase.
89
+ startup = Future()
90
+ shutdown_complete = Future()
80
91
 
81
92
  async def shutdown():
82
- shutdown_future.set_result(None)
83
- await sleep(0)
93
+ # Nothing to shut down if the app never completed a lifespan startup.
94
+ if startup.done() and not startup.result():
95
+ return
96
+ await receive_queue.put({"type": "lifespan.shutdown"})
97
+ await shutdown_complete
84
98
 
85
- it = iter([{"type": "lifespan.startup"}, Future()])
99
+ async def no_lifespan_shutdown():
100
+ return
86
101
 
87
102
  async def receive():
88
- res = next(it)
89
- if isawaitable(res):
90
- await res
91
- return res
92
-
93
- ready = Future()
103
+ return await receive_queue.get()
94
104
 
95
105
  async def send(got):
96
106
  if got["type"] == "lifespan.startup.complete":
97
- ready.set_result(None)
107
+ if not startup.done():
108
+ startup.set_result(True)
109
+ return
110
+ if got["type"] == "lifespan.startup.failed":
111
+ message = got.get("message", "ASGI lifespan startup failed")
112
+ if not startup.done():
113
+ startup.set_exception(RuntimeError(message))
98
114
  return
99
115
  if got["type"] == "lifespan.shutdown.complete":
116
+ if not shutdown_complete.done():
117
+ shutdown_complete.set_result(None)
118
+ return
119
+ if got["type"] == "lifespan.shutdown.failed":
120
+ message = got.get("message", "ASGI lifespan shutdown failed")
121
+ if not shutdown_complete.done():
122
+ shutdown_complete.set_exception(RuntimeError(message))
100
123
  return
101
124
  raise RuntimeError(f"Unexpected lifespan event {got['type']}")
102
125
 
103
- run_in_background(
104
- app(
105
- {
106
- "asgi": ASGI,
107
- "state": {},
108
- "type": "lifespan",
109
- },
110
- receive,
111
- send,
112
- )
113
- )
114
- await ready
126
+ async def run_lifespan():
127
+ try:
128
+ await app(
129
+ {
130
+ "asgi": ASGI,
131
+ "state": {},
132
+ "type": "lifespan",
133
+ },
134
+ receive,
135
+ send,
136
+ )
137
+ # App returned without acking: a missing startup ack means it has no
138
+ # lifespan handler, so mark startup unsupported rather than hanging.
139
+ if not startup.done():
140
+ startup.set_result(False)
141
+ elif not shutdown_complete.done():
142
+ shutdown_complete.set_result(None)
143
+ except Exception as exc:
144
+ # Spec: an exception raised before startup is acked signals that the
145
+ # app doesn't support lifespan; swallow it and serve requests anyway.
146
+ if not startup.done():
147
+ startup.set_result(False)
148
+ return
149
+ # After a successful startup, a shutdown-phase error can't affect the
150
+ # already-served request, so log it and let shutdown complete.
151
+ logger.exception("Exception in ASGI lifespan application", exc_info=exc)
152
+ if not shutdown_complete.done():
153
+ shutdown_complete.set_result(None)
154
+
155
+ run_in_background(run_lifespan())
156
+ supported = await startup
157
+ if not supported:
158
+ return no_lifespan_shutdown
115
159
  return shutdown
116
160
 
117
161
 
@@ -163,10 +207,10 @@ async def process_request(
163
207
  if got["type"] == "http.response.start":
164
208
  status = got["status"]
165
209
  # Like above, we need to convert byte-pairs into string explicitly.
166
- headers = [(k.decode(), v.decode()) for k, v in got["headers"]]
210
+ headers = [(k.decode(), v.decode()) for k, v in got.get("headers", [])]
167
211
 
168
212
  elif got["type"] == "http.response.body":
169
- body = got["body"]
213
+ body = got.get("body", b"")
170
214
  more_body = got.get("more_body", False)
171
215
 
172
216
  if writer is not None:
@@ -189,6 +233,14 @@ async def process_request(
189
233
  result.set_result(resp)
190
234
  with acquire_js_buffer(body) as jsbytes:
191
235
  await writer.write(jsbytes.slice())
236
+ elif status in NULL_BODY_STATUSES:
237
+ # 101/103/204/205/304 must not carry a body per the Fetch spec.
238
+ # https://fetch.spec.whatwg.org/#null-body-status
239
+ resp = Response.new(
240
+ None, headers=Object.fromEntries(headers), status=status
241
+ )
242
+ result.set_result(resp)
243
+ finished_response.set()
192
244
  else:
193
245
  # Complete body in a single chunk
194
246
  px = create_proxy(body)
@@ -0,0 +1,372 @@
1
+ import io
2
+ import logging
3
+ import sys
4
+ from collections.abc import Callable, Iterator
5
+ from typing import Any
6
+ from urllib.parse import unquote, urlsplit
7
+
8
+ import js
9
+
10
+ from workers import Context, Request
11
+
12
+ logger = logging.getLogger("wsgi")
13
+
14
+
15
+ def _wsgi_native_string(value: str) -> str:
16
+ """Convert a (possibly non-ASCII) Python ``str`` into a WSGI "native string".
17
+
18
+ PEP 3333 requires that strings handed to a WSGI application use the
19
+ bytes-in-unicode convention: the string contains characters whose code
20
+ points correspond to the raw bytes (i.e. it is decoded with ``latin-1``).
21
+ Frameworks such as Werkzeug/Flask undo this by re-encoding with ``latin-1``
22
+ and decoding with ``utf-8``.
23
+ """
24
+ return value.encode("utf-8").decode("latin-1")
25
+
26
+
27
+ class _ReadableStreamInput(io.RawIOBase):
28
+ """A blocking, file-like ``wsgi.input`` backed by an async ``ReadableStream``.
29
+
30
+ WSGI apps read the request body synchronously, but the Workers body is an
31
+ async ``ReadableStream``. We bridge the two lazily using Pyodide's
32
+ ``run_sync`` (JSPI stack switching): each ``readinto`` pulls only as much
33
+ from the stream as the application asks for, so the body is never fully
34
+ buffered up-front.
35
+
36
+ This only works while a JSPI suspender is on the stack, which is the case
37
+ here because the WSGI app runs synchronously inside the async ``fetch``
38
+ handler.
39
+ """
40
+
41
+ def __init__(self, js_body: "js.ReadableStream") -> None:
42
+ self._reader = js_body.getReader()
43
+ self._buf = bytearray()
44
+ self._eof = False
45
+
46
+ def readable(self) -> bool:
47
+ return True
48
+
49
+ def _fill(self) -> None:
50
+ from pyodide.ffi import run_sync
51
+
52
+ while not self._buf and not self._eof:
53
+ result = run_sync(self._reader.read())
54
+ if result.done:
55
+ self._eof = True
56
+ elif result.value is not None:
57
+ self._buf.extend(result.value.to_bytes())
58
+
59
+ def readinto(self, b) -> int:
60
+ self._fill()
61
+ if not self._buf:
62
+ return 0
63
+ n = min(len(b), len(self._buf))
64
+ b[:n] = self._buf[:n]
65
+ del self._buf[:n]
66
+ return n
67
+
68
+ def close(self) -> None:
69
+ if not self.closed:
70
+ self._release_reader()
71
+ super().close()
72
+
73
+ def __del__(self):
74
+ self.close()
75
+
76
+ def _release_reader(self) -> None:
77
+ """Discard the request body and release the underlying reader.
78
+
79
+ If the body was fully consumed we just release the lock. Otherwise we
80
+ ``cancel()`` the reader to drop the remainder; ``cancel()`` returns a JS
81
+ promise that the runtime settles on its own, so we don't await it.
82
+ """
83
+ from pyodide.ffi import run_sync
84
+
85
+ try:
86
+ if self._eof:
87
+ self._reader.releaseLock()
88
+ else:
89
+ run_sync(self._reader.cancel())
90
+ except Exception: # noqa: BLE001 - best-effort cleanup
91
+ pass
92
+
93
+
94
+ def _make_wsgi_input(req: "Request | js.Request") -> "io.BufferedIOBase | None":
95
+ """Build a lazy, stream-backed ``wsgi.input`` if the runtime supports it.
96
+
97
+ Returns ``None`` when lazy streaming is unavailable, signalling the caller
98
+ to fall back to pre-buffering the body.
99
+ """
100
+ if not req.body:
101
+ return io.BytesIO(b"")
102
+ # `req.body` is a JS ReadableStream for both workers.Request and js.Request.
103
+ return io.BufferedReader(_ReadableStreamInput(req.body))
104
+
105
+
106
+ async def _read_body(req: "Request | js.Request") -> bytes:
107
+ """Read the entire request body into memory (fallback when streaming is off)."""
108
+ if not req.body:
109
+ return b""
110
+ chunks = [data.to_bytes() async for data in req.body]
111
+ return b"".join(chunks)
112
+
113
+
114
+ def build_environ(
115
+ req: "Request | js.Request",
116
+ env: dict[str, Any],
117
+ body: "bytes | io.IOBase",
118
+ ) -> dict[str, Any]:
119
+ # `body` may be raw bytes or a file-like `wsgi.input` stream
120
+ if isinstance(body, (bytes, bytearray)):
121
+ wsgi_input: Any = io.BytesIO(body)
122
+ content_length_fallback: int | None = len(body)
123
+ else:
124
+ wsgi_input = body
125
+ content_length_fallback = None
126
+
127
+ req_headers = req.headers.items() if isinstance(req, Request) else req.headers
128
+
129
+ url = urlsplit(req.url)
130
+ scheme = url.scheme
131
+ if not scheme:
132
+ raise ValueError(f"Request URL is not absolute: {req.url!r}")
133
+
134
+ # PATH_INFO is the URL-decoded path expressed as a WSGI native string.
135
+ path_info = _wsgi_native_string(unquote(url.path or "/"))
136
+ # QUERY_STRING stays percent-encoded per the spec.
137
+ query_string = url.query
138
+
139
+ server_port = str(url.port) if url.port else ("443" if scheme == "https" else "80")
140
+
141
+ method = req.method
142
+ # `workers.Request.method` is an `http.HTTPMethod` (a str subclass); coerce
143
+ # to a plain string so frameworks comparing against literals behave.
144
+ method = str(method.value) if hasattr(method, "value") else str(method)
145
+
146
+ environ: dict[str, Any] = {
147
+ "REQUEST_METHOD": method,
148
+ "SCRIPT_NAME": "",
149
+ "PATH_INFO": path_info,
150
+ "QUERY_STRING": query_string,
151
+ # A request URL is always absolute here, so hostname is populated; keep a
152
+ # fallback since WSGI requires SERVER_NAME to be non-empty.
153
+ "SERVER_NAME": url.hostname,
154
+ "SERVER_PORT": server_port,
155
+ "SERVER_PROTOCOL": "HTTP/1.1",
156
+ "wsgi.version": (1, 0),
157
+ "wsgi.url_scheme": scheme,
158
+ "wsgi.input": wsgi_input,
159
+ "wsgi.errors": sys.stderr,
160
+ "wsgi.multithread": False,
161
+ "wsgi.multiprocess": False,
162
+ "wsgi.run_once": False,
163
+ # Cloudflare-specific extension so handlers can reach bindings, mirroring
164
+ # the `scope["env"]` convention used by asgi.py.
165
+ "workers.env": env,
166
+ }
167
+
168
+ for key, value in req_headers:
169
+ name = key.upper().replace("-", "_")
170
+ if name in ("CONTENT_TYPE", "CONTENT_LENGTH"):
171
+ environ[name] = value
172
+ else:
173
+ http_name = "HTTP_" + name
174
+ if http_name in environ:
175
+ # Repeated headers are folded into a single comma-separated value.
176
+ environ[http_name] += "," + value
177
+ else:
178
+ environ[http_name] = value
179
+
180
+ # Only synthesize CONTENT_LENGTH when the body was pre-buffered; when
181
+ # streaming lazily we don't know the length without consuming it.
182
+ if "CONTENT_LENGTH" not in environ and content_length_fallback:
183
+ environ["CONTENT_LENGTH"] = str(content_length_fallback)
184
+
185
+ return environ
186
+
187
+
188
+ def _to_js_uint8array(chunk: bytes) -> "js.Uint8Array":
189
+ """Copy Python ``bytes`` into a standalone, JS-owned ``Uint8Array``.
190
+
191
+ A copy (rather than a view over the WASM heap) is required because
192
+ ``ReadableStreamDefaultController.enqueue`` keeps the chunk by reference in
193
+ the stream's internal queue until the consumer reads it later, by which
194
+ point ``chunk`` has been released. ``to_js`` copies buffer data out of the
195
+ heap into a fresh ``ArrayBuffer`` (pyodide's ``python2js_buffer``), so its
196
+ result stays valid.
197
+ """
198
+ from pyodide.ffi import to_js
199
+
200
+ return to_js(chunk)
201
+
202
+
203
+ def _close_iterable(iterable: Any) -> None:
204
+ # PEP 3333: if the iterable has a close() method, the server must call it.
205
+ close = getattr(iterable, "close", None)
206
+ if close is not None:
207
+ close()
208
+
209
+
210
+ # Sentinel marking exhaustion of the response body iterator.
211
+ _END = object()
212
+
213
+
214
+ def _make_streaming_response(
215
+ status: str,
216
+ headers: "list[tuple[str, str]]",
217
+ chunks: "Iterator[bytes]",
218
+ on_close: "Callable[[], None]",
219
+ ) -> js.Response:
220
+ """Build a ``js.Response`` whose body is pulled lazily from *chunks*.
221
+
222
+ Uses a ``ReadableStream`` with a synchronous, pull-based source so response
223
+ bytes are produced on demand as the client reads, instead of buffering the
224
+ whole body in memory. ``on_close`` runs exactly once when the stream is
225
+ exhausted, errored, or cancelled.
226
+ """
227
+ from js import Headers as JsHeaders
228
+ from js import ReadableStream, Response
229
+ from pyodide.ffi import create_proxy
230
+
231
+ # WSGI status is e.g. "200 OK"; split into code + reason phrase.
232
+ code_str, _, reason = status.partition(" ")
233
+ options: dict[str, Any] = {"status": int(code_str)}
234
+ if reason:
235
+ options["statusText"] = reason
236
+
237
+ js_headers = JsHeaders.new()
238
+ for key, value in headers:
239
+ # `append` (not `set`) preserves repeated headers such as Set-Cookie.
240
+ js_headers.append(key, value)
241
+ options["headers"] = js_headers
242
+
243
+ proxies: list[Any] = []
244
+ done = False
245
+
246
+ def cleanup() -> None:
247
+ nonlocal done
248
+ if done:
249
+ return
250
+ done = True
251
+ try:
252
+ on_close()
253
+ finally:
254
+ for proxy in proxies:
255
+ proxy.destroy()
256
+
257
+ @create_proxy
258
+ def pull(controller: Any) -> None:
259
+ try:
260
+ chunk = next(chunks, _END)
261
+ except Exception as exc: # noqa: BLE001 - forward app errors to the stream
262
+ logger.exception("Exception while streaming WSGI response body")
263
+ cleanup()
264
+ controller.error(str(exc))
265
+ return
266
+ if chunk is _END:
267
+ controller.close()
268
+ cleanup()
269
+ return
270
+ controller.enqueue(_to_js_uint8array(chunk))
271
+
272
+ @create_proxy
273
+ def cancel(_reason: Any = None) -> None:
274
+ cleanup()
275
+
276
+ proxies = [pull, cancel]
277
+ return Response.new(ReadableStream.new(pull=pull, cancel=cancel), **options)
278
+
279
+
280
+ def process_request(
281
+ app: Any,
282
+ req: "Request | js.Request",
283
+ env: Any,
284
+ body: "bytes | io.IOBase",
285
+ ) -> js.Response:
286
+ environ = build_environ(req, env, body)
287
+
288
+ response_state: dict[str, Any] = {"status": None, "headers": None}
289
+ # Output from the (rarely used) legacy `write()` callable. Modern frameworks
290
+ # (Flask, Werkzeug, Django) return an iterable instead; when they do use
291
+ # write(), that output is emitted before the iterable's chunks.
292
+ write_chunks: list[bytes] = []
293
+
294
+ def write(chunk: bytes) -> None:
295
+ if response_state["status"] is None:
296
+ raise AssertionError("write() called before start_response()")
297
+ if chunk:
298
+ write_chunks.append(chunk)
299
+
300
+ def start_response(status, response_headers, exc_info=None):
301
+ if exc_info is not None:
302
+ try:
303
+ if response_state["status"] is not None:
304
+ # Headers were already sent; re-raise the original error.
305
+ raise exc_info[1].with_traceback(exc_info[2])
306
+ finally:
307
+ exc_info = None
308
+ elif response_state["status"] is not None:
309
+ raise AssertionError("start_response() called more than once")
310
+
311
+ response_state["status"] = status
312
+ response_state["headers"] = response_headers
313
+ return write
314
+
315
+ result = app(environ, start_response)
316
+ result_iter = iter(result)
317
+
318
+ def close_all() -> None:
319
+ _close_iterable(result)
320
+ try:
321
+ environ["wsgi.input"].close()
322
+ except Exception: # noqa: BLE001 - best-effort cleanup
323
+ logger.exception("Failed to close wsgi.input")
324
+
325
+ # WSGI apps must call start_response before yielding the first body chunk,
326
+ # but some defer it until the first non-empty chunk is produced. Pull that
327
+ # chunk now so status/headers are known before we construct the Response.
328
+ try:
329
+ first_chunk: Any = _END
330
+ for chunk in result_iter:
331
+ if chunk:
332
+ first_chunk = chunk
333
+ break
334
+ except Exception:
335
+ close_all()
336
+ raise
337
+
338
+ if response_state["status"] is None:
339
+ close_all()
340
+ raise RuntimeError("The WSGI application did not call start_response()")
341
+
342
+ def body_chunks() -> "Iterator[bytes]":
343
+ yield from write_chunks
344
+ if first_chunk is not _END:
345
+ yield first_chunk
346
+ for chunk in result_iter:
347
+ if chunk:
348
+ yield chunk
349
+
350
+ return _make_streaming_response(
351
+ response_state["status"], response_state["headers"], body_chunks(), close_all
352
+ )
353
+
354
+
355
+ async def fetch(
356
+ app: Any,
357
+ req: "Request | js.Request",
358
+ env: Any,
359
+ # Accepted for parity with asgi.fetch; WSGI has no use for it.
360
+ ctx: Context | None = None,
361
+ ) -> js.Response:
362
+ logger.debug("WSGI request: %s %s", req.method, req.url)
363
+ # Prefer lazily streaming the body through `wsgi.input` (no full buffering);
364
+ # fall back to pre-buffering when `run_sync`/JSPI isn't available.
365
+ body: bytes | io.IOBase | None = _make_wsgi_input(req)
366
+ if body is None:
367
+ body = await _read_body(req)
368
+ try:
369
+ return process_request(app, req, env, body)
370
+ except Exception:
371
+ logger.exception("WSGI request failed")
372
+ raise
@@ -229,7 +229,7 @@ wheels = [
229
229
 
230
230
  [[package]]
231
231
  name = "workers-runtime-sdk"
232
- version = "1.5.4"
232
+ version = "1.6.1"
233
233
  source = { editable = "." }
234
234
 
235
235
  [package.dev-dependencies]