crosswind-workers 0.1.0__py3-none-any.whl
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.
- _crosswind_patches/__init__.py +13 -0
- _crosswind_patches/entropy.py +259 -0
- _crosswind_patches/runtime.py +45 -0
- asgi/__init__.py +7 -0
- crosswind_workers-0.1.0.data/purelib/crosswind_workers_patches.pth +1 -0
- crosswind_workers-0.1.0.dist-info/METADATA +9 -0
- crosswind_workers-0.1.0.dist-info/RECORD +35 -0
- crosswind_workers-0.1.0.dist-info/WHEEL +4 -0
- workers/__init__.py +88 -0
- workers/_adapter.py +24 -0
- workers/_body.py +34 -0
- workers/_js.py +121 -0
- workers/asgi.py +523 -0
- workers/bindings/__init__.py +36 -0
- workers/bindings/base.py +135 -0
- workers/bindings/d1.py +47 -0
- workers/bindings/env.py +50 -0
- workers/bindings/kv.py +121 -0
- workers/bindings/queues.py +36 -0
- workers/bindings/r2.py +130 -0
- workers/bindings/services.py +55 -0
- workers/bindings/workflow.py +64 -0
- workers/blob.py +158 -0
- workers/context.py +230 -0
- workers/entrypoints.py +183 -0
- workers/fetch.py +51 -0
- workers/formdata.py +92 -0
- workers/py.typed +0 -0
- workers/request.py +138 -0
- workers/response.py +181 -0
- workers/rpc.py +154 -0
- workers/types.py +57 -0
- workers/utils.py +55 -0
- workers/workflows.py +265 -0
- workers/wsgi.py +426 -0
workers/workflows.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Workflow steps in pure Python.
|
|
2
|
+
|
|
3
|
+
The stock SDK ships one JavaScript helper, which renames a Python
|
|
4
|
+
`NonRetryableError` so the Workflows engine stops retrying the step. A Python
|
|
5
|
+
`raise` can't do that, because Pyodide wraps it in a fresh `PythonError`. Here
|
|
6
|
+
the step callback instead returns a thenable that rejects with a JS `Error` of
|
|
7
|
+
that name, and the engine adopts the rejection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import inspect
|
|
12
|
+
from asyncio import Task, create_task, gather
|
|
13
|
+
from collections.abc import Callable, Coroutine
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import js
|
|
17
|
+
from pyodide.ffi import JsException, create_once_callable, to_js
|
|
18
|
+
|
|
19
|
+
from ._js import compat_flag
|
|
20
|
+
from .bindings import Binding
|
|
21
|
+
from .rpc import python_from_rpc, python_to_rpc
|
|
22
|
+
|
|
23
|
+
__all__ = ["NonRetryableError", "WorkflowStep"]
|
|
24
|
+
|
|
25
|
+
NON_RETRYABLE = "NonRetryableError"
|
|
26
|
+
|
|
27
|
+
type Step = Callable[[], Coroutine[Any, Any, Any]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NonRetryableError(Exception):
|
|
31
|
+
"""Raised inside a step, stops the Workflows engine from retrying it."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WorkflowStep(Binding):
|
|
35
|
+
"""The `step` a Workflow's `run` receives. Other names pass through."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, js_object: Any) -> None:
|
|
38
|
+
if js_object is self:
|
|
39
|
+
return
|
|
40
|
+
super().__init__(js_object)
|
|
41
|
+
self._results: dict[Step, Any] = {}
|
|
42
|
+
self._in_flight: dict[Step, Task[Any]] = {}
|
|
43
|
+
# Each step by its step name and by its function's name.
|
|
44
|
+
self._steps: dict[str, Step] = {}
|
|
45
|
+
|
|
46
|
+
def do(
|
|
47
|
+
self,
|
|
48
|
+
name: str | None = None,
|
|
49
|
+
depends: list[Step] | None = None,
|
|
50
|
+
concurrent: bool = False,
|
|
51
|
+
config: dict[str, Any] | None = None,
|
|
52
|
+
) -> Callable[[Callable[..., Any]], Step]:
|
|
53
|
+
"""Decorate a function as a step and return an async callable that runs it.
|
|
54
|
+
|
|
55
|
+
With `python_workflows_implicit_dependencies`, a parameter named after
|
|
56
|
+
an earlier step receives its result. Without it, `depends` lists them.
|
|
57
|
+
A parameter named `ctx` receives the step context either way.
|
|
58
|
+
"""
|
|
59
|
+
implicit = compat_flag("python_workflows_implicit_dependencies")
|
|
60
|
+
if implicit and depends is not None:
|
|
61
|
+
raise TypeError(
|
|
62
|
+
"depends is not accepted with python_workflows_implicit_dependencies;"
|
|
63
|
+
" name parameters after the steps they depend on"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def decorator(func: Callable[..., Any]) -> Step:
|
|
67
|
+
func_name: str = getattr(func, "__name__", name or "")
|
|
68
|
+
step_name = func_name if name is None else name
|
|
69
|
+
parameters = list(inspect.signature(func).parameters)
|
|
70
|
+
dependencies = self._dependencies(parameters, depends, implicit)
|
|
71
|
+
|
|
72
|
+
async def step() -> Any:
|
|
73
|
+
if concurrent:
|
|
74
|
+
results = await gather(*map(self._resolve, dependencies))
|
|
75
|
+
else:
|
|
76
|
+
results = [await self._resolve(d) for d in dependencies]
|
|
77
|
+
return await self._run(
|
|
78
|
+
step, step_name, config, func, parameters, results
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# By function name too, so a step named "ctx-step" can be a dependency.
|
|
82
|
+
self._steps[step_name] = self._steps[func_name] = step
|
|
83
|
+
return step
|
|
84
|
+
|
|
85
|
+
return decorator
|
|
86
|
+
|
|
87
|
+
async def sleep(self, *args: Any, **kwargs: Any) -> Any:
|
|
88
|
+
return await self.js_object.sleep(*args, **kwargs)
|
|
89
|
+
|
|
90
|
+
async def sleep_until(self, name: str, timestamp: Any) -> Any:
|
|
91
|
+
if not isinstance(timestamp, str):
|
|
92
|
+
timestamp = python_to_rpc(timestamp)
|
|
93
|
+
return await self.js_object.sleepUntil(name, timestamp)
|
|
94
|
+
|
|
95
|
+
async def wait_for_event(
|
|
96
|
+
self, name: str, event_type: str, /, timeout: str = "24 hours"
|
|
97
|
+
) -> Any:
|
|
98
|
+
options = _js_object({"type": event_type, "timeout": timeout})
|
|
99
|
+
return python_from_rpc(await self.js_object.waitForEvent(name, options))
|
|
100
|
+
|
|
101
|
+
def _dependencies(
|
|
102
|
+
self, parameters: list[str], depends: list[Step] | None, implicit: bool
|
|
103
|
+
) -> list[Step]:
|
|
104
|
+
"""The steps whose results fill the parameters other than ctx."""
|
|
105
|
+
others = [p for p in parameters if p != "ctx"]
|
|
106
|
+
if not implicit:
|
|
107
|
+
return self._listed_dependencies(others, depends)
|
|
108
|
+
|
|
109
|
+
for parameter in others:
|
|
110
|
+
if parameter not in self._steps:
|
|
111
|
+
raise TypeError(f"Received unexpected parameter {parameter}")
|
|
112
|
+
|
|
113
|
+
return [self._steps[p] for p in others]
|
|
114
|
+
|
|
115
|
+
def _listed_dependencies(
|
|
116
|
+
self, others: list[str], depends: list[Step] | None
|
|
117
|
+
) -> list[Step]:
|
|
118
|
+
"""The steps `depends` lists, checked against the parameters they fill."""
|
|
119
|
+
if depends is None and others:
|
|
120
|
+
raise TypeError(
|
|
121
|
+
f"Step has {len(others)} non-ctx parameter(s) but no 'depends' "
|
|
122
|
+
"list provided"
|
|
123
|
+
)
|
|
124
|
+
depends = list(depends or [])
|
|
125
|
+
|
|
126
|
+
if len(depends) != len(others):
|
|
127
|
+
raise TypeError(
|
|
128
|
+
f"Step declares {len(others)} non-ctx parameter(s) but 'depends' "
|
|
129
|
+
f"has {len(depends)} item(s)"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
for parameter, dependency in zip(others, depends, strict=True):
|
|
133
|
+
if dependency not in self._steps.values():
|
|
134
|
+
raise TypeError(
|
|
135
|
+
f"'depends' entry for parameter {parameter!r} is not a "
|
|
136
|
+
"function decorated with step.do"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return depends
|
|
140
|
+
|
|
141
|
+
async def _resolve(self, dependency: Step) -> Any:
|
|
142
|
+
if dependency in self._results:
|
|
143
|
+
return self._results[dependency]
|
|
144
|
+
|
|
145
|
+
# Registered before awaiting, so a concurrent branch reuses this run.
|
|
146
|
+
if dependency not in self._in_flight:
|
|
147
|
+
self._in_flight[dependency] = create_task(dependency())
|
|
148
|
+
return await self._in_flight[dependency]
|
|
149
|
+
|
|
150
|
+
async def _run(
|
|
151
|
+
self,
|
|
152
|
+
key: Step,
|
|
153
|
+
name: str,
|
|
154
|
+
config: dict[str, Any] | None,
|
|
155
|
+
func: Callable[..., Any],
|
|
156
|
+
parameters: list[str],
|
|
157
|
+
results: list[Any],
|
|
158
|
+
) -> Any:
|
|
159
|
+
async def callback(ctx: Any = None) -> Any:
|
|
160
|
+
given = iter(results)
|
|
161
|
+
args = [
|
|
162
|
+
python_from_rpc(ctx) if p == "ctx" else next(given) for p in parameters
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
result = func(*args)
|
|
167
|
+
if inspect.isawaitable(result):
|
|
168
|
+
result = await result
|
|
169
|
+
except NonRetryableError as error:
|
|
170
|
+
return _rejecting_thenable(error)
|
|
171
|
+
|
|
172
|
+
# The result crosses RPC back to the Workflows engine.
|
|
173
|
+
return python_to_rpc(result)
|
|
174
|
+
|
|
175
|
+
async def call() -> Any:
|
|
176
|
+
args = [name] if config is None else [name, _js_object(config)]
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
return python_from_rpc(await self.js_object.do(*args, callback))
|
|
180
|
+
except JsException as error:
|
|
181
|
+
python_error = _python_error(error)
|
|
182
|
+
if python_error is error:
|
|
183
|
+
raise
|
|
184
|
+
raise python_error from error
|
|
185
|
+
|
|
186
|
+
task = create_task(call())
|
|
187
|
+
self._in_flight[key] = task
|
|
188
|
+
try:
|
|
189
|
+
self._results[key] = result = await task
|
|
190
|
+
finally:
|
|
191
|
+
self._in_flight.pop(key, None)
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def wrap_run(run: Callable[..., Any]) -> Callable[..., Any]:
|
|
197
|
+
"""Convert the event and step workerd passes `run` as raw JS values."""
|
|
198
|
+
|
|
199
|
+
@functools.wraps(run)
|
|
200
|
+
async def wrapped(
|
|
201
|
+
self: Any, event: Any = None, step: Any = None, /, *args: Any, **kwargs: Any
|
|
202
|
+
) -> Any:
|
|
203
|
+
if event is not None:
|
|
204
|
+
event = python_from_rpc(event)
|
|
205
|
+
if step is not None:
|
|
206
|
+
step = WorkflowStep(step)
|
|
207
|
+
|
|
208
|
+
result = run(self, event, step, *args, **kwargs)
|
|
209
|
+
if inspect.isawaitable(result):
|
|
210
|
+
result = await result
|
|
211
|
+
|
|
212
|
+
return None if result is None else python_to_rpc(result)
|
|
213
|
+
|
|
214
|
+
return wrapped
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _js_object(value: dict[str, Any]) -> Any:
|
|
218
|
+
return to_js(value, dict_converter=js.Object.fromEntries)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _rejecting_thenable(error: NonRetryableError) -> Any:
|
|
222
|
+
js_error = js.Error.new(str(error))
|
|
223
|
+
js_error.name = NON_RETRYABLE
|
|
224
|
+
# A thenable, so no rejected promise exists before the engine adopts it.
|
|
225
|
+
return _js_object(
|
|
226
|
+
{"then": create_once_callable(lambda _resolve, reject: reject(js_error))}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# The Python exception for a traceback's last line, by the line's prefix.
|
|
231
|
+
_TRACEBACK_ERRORS: dict[str, type[Exception]] = {
|
|
232
|
+
"TypeError": TypeError,
|
|
233
|
+
"ValueError": ValueError,
|
|
234
|
+
"workers.workflows.NonRetryableError": NonRetryableError,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# The Python exception for a JS error, by the error's name.
|
|
238
|
+
_JS_ERRORS: dict[str | None, type[Exception]] = {
|
|
239
|
+
"RangeError": ValueError,
|
|
240
|
+
"TypeError": TypeError,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _python_error(error: JsException) -> Exception:
|
|
245
|
+
"""The Python exception for a step error the engine rethrew."""
|
|
246
|
+
message = error.message or ""
|
|
247
|
+
|
|
248
|
+
# The name survives the engine, or is folded into the message.
|
|
249
|
+
if error.name == NON_RETRYABLE:
|
|
250
|
+
return NonRetryableError(message)
|
|
251
|
+
if message == NON_RETRYABLE or message.startswith(f"{NON_RETRYABLE}: "):
|
|
252
|
+
return NonRetryableError(message[len(NON_RETRYABLE) + 2 :])
|
|
253
|
+
|
|
254
|
+
# A Python exception that escaped a step comes back as a PythonError whose
|
|
255
|
+
# message is the traceback, ending with the exception's own line.
|
|
256
|
+
lines = message.rstrip().split("\n")
|
|
257
|
+
if len(lines) > 1 and (
|
|
258
|
+
error.name == "PythonError" or message.startswith("PythonError")
|
|
259
|
+
):
|
|
260
|
+
for prefix, error_type in _TRACEBACK_ERRORS.items():
|
|
261
|
+
if lines[-1].startswith(prefix):
|
|
262
|
+
return error_type(lines[-1])
|
|
263
|
+
|
|
264
|
+
error_type = _JS_ERRORS.get(error.name)
|
|
265
|
+
return error if error_type is None else error_type(message)
|
workers/wsgi.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Serve a WSGI application, such as Flask, from a Worker.
|
|
2
|
+
|
|
3
|
+
Ported from the stock SDK at cb2db3c. `wsgi.input` streams the request body
|
|
4
|
+
through `run_sync`, which Python 3.12 Workers lack, so there the body is read
|
|
5
|
+
up front instead.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import SplitResult, unquote, urlsplit
|
|
14
|
+
|
|
15
|
+
import js
|
|
16
|
+
import pyodide.ffi
|
|
17
|
+
|
|
18
|
+
from ._adapter import NULL_BODY_STATUSES, body_encoding, request_headers
|
|
19
|
+
from .context import ExecutionContext
|
|
20
|
+
from .entrypoints import WorkerEntrypoint
|
|
21
|
+
from .request import Request
|
|
22
|
+
from .types import Context
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("wsgi")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _wsgi_native_string(value: str) -> str:
|
|
28
|
+
"""Convert a (possibly non-ASCII) Python ``str`` into a WSGI "native string".
|
|
29
|
+
|
|
30
|
+
PEP 3333 requires that strings handed to a WSGI application use the
|
|
31
|
+
bytes-in-unicode convention: the string contains characters whose code
|
|
32
|
+
points correspond to the raw bytes (i.e. it is decoded with ``latin-1``).
|
|
33
|
+
Frameworks such as Werkzeug/Flask undo this by re-encoding with ``latin-1``
|
|
34
|
+
and decoding with ``utf-8``.
|
|
35
|
+
"""
|
|
36
|
+
return value.encode("utf-8").decode("latin-1")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _ReadableStreamInput(io.RawIOBase):
|
|
40
|
+
"""A blocking, file-like ``wsgi.input`` backed by an async ``ReadableStream``.
|
|
41
|
+
|
|
42
|
+
WSGI apps read the request body synchronously, but the Workers body is an
|
|
43
|
+
async ``ReadableStream``. We bridge the two lazily using Pyodide's
|
|
44
|
+
``run_sync`` (JSPI stack switching): each ``readinto`` pulls only as much
|
|
45
|
+
from the stream as the application asks for, so the body is never fully
|
|
46
|
+
buffered up-front.
|
|
47
|
+
|
|
48
|
+
This only works while a JSPI suspender is on the stack, which is the case
|
|
49
|
+
here because the WSGI app runs synchronously inside the async ``fetch``
|
|
50
|
+
handler.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, js_body: "js.ReadableStream") -> None:
|
|
54
|
+
self._reader = js_body.getReader()
|
|
55
|
+
self._buf = bytearray()
|
|
56
|
+
self._eof = False
|
|
57
|
+
|
|
58
|
+
def readable(self) -> bool:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def _fill(self) -> None:
|
|
62
|
+
from pyodide.ffi import run_sync
|
|
63
|
+
|
|
64
|
+
while not self._buf and not self._eof:
|
|
65
|
+
result = run_sync(self._reader.read())
|
|
66
|
+
if result.done:
|
|
67
|
+
self._eof = True
|
|
68
|
+
elif result.value is not None:
|
|
69
|
+
self._buf.extend(result.value.to_bytes())
|
|
70
|
+
|
|
71
|
+
def readinto(self, b) -> int:
|
|
72
|
+
self._fill()
|
|
73
|
+
if not self._buf:
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
n = min(len(b), len(self._buf))
|
|
77
|
+
b[:n] = self._buf[:n]
|
|
78
|
+
del self._buf[:n]
|
|
79
|
+
return n
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
if not self.closed:
|
|
83
|
+
self._release_reader()
|
|
84
|
+
super().close()
|
|
85
|
+
|
|
86
|
+
def __del__(self):
|
|
87
|
+
self.close()
|
|
88
|
+
|
|
89
|
+
def _release_reader(self) -> None:
|
|
90
|
+
"""Discard the request body and release the underlying reader.
|
|
91
|
+
|
|
92
|
+
If the body was fully consumed we just release the lock. Otherwise we
|
|
93
|
+
``cancel()`` the reader to drop the remainder; ``cancel()`` returns a JS
|
|
94
|
+
promise that the runtime settles on its own, so we don't await it.
|
|
95
|
+
"""
|
|
96
|
+
from pyodide.ffi import run_sync
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
if self._eof:
|
|
100
|
+
self._reader.releaseLock()
|
|
101
|
+
else:
|
|
102
|
+
run_sync(self._reader.cancel())
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.debug("Releasing the request body failed", exc_info=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _make_wsgi_input(req: "Request | js.Request") -> "io.BufferedIOBase | None":
|
|
108
|
+
"""Build a lazy, stream-backed ``wsgi.input`` if the runtime supports it.
|
|
109
|
+
|
|
110
|
+
Returns ``None`` when lazy streaming is unavailable, signalling the caller
|
|
111
|
+
to fall back to pre-buffering the body.
|
|
112
|
+
"""
|
|
113
|
+
if not req.body:
|
|
114
|
+
return io.BytesIO(b"")
|
|
115
|
+
if not hasattr(pyodide.ffi, "run_sync"):
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
# `req.body` is a JS ReadableStream for both workers.Request and js.Request.
|
|
119
|
+
return io.BufferedReader(_ReadableStreamInput(req.body))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _read_body(req: "Request | js.Request") -> bytes:
|
|
123
|
+
"""Read the entire request body into memory (fallback when streaming is off)."""
|
|
124
|
+
if not req.body:
|
|
125
|
+
return b""
|
|
126
|
+
|
|
127
|
+
chunks = [data.to_bytes() async for data in req.body]
|
|
128
|
+
return b"".join(chunks)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def build_environ(
|
|
132
|
+
req: "Request | js.Request",
|
|
133
|
+
env: dict[str, Any],
|
|
134
|
+
body: "bytes | io.IOBase",
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
url = urlsplit(req.url)
|
|
137
|
+
if not url.scheme:
|
|
138
|
+
raise ValueError(f"Request URL is not absolute: {req.url!r}")
|
|
139
|
+
|
|
140
|
+
# `body` may be raw bytes or a file-like `wsgi.input` stream
|
|
141
|
+
if isinstance(body, (bytes, bytearray)):
|
|
142
|
+
wsgi_input: Any = io.BytesIO(body)
|
|
143
|
+
content_length_fallback: int | None = len(body)
|
|
144
|
+
else:
|
|
145
|
+
wsgi_input = body
|
|
146
|
+
content_length_fallback = None
|
|
147
|
+
|
|
148
|
+
environ: dict[str, Any] = {
|
|
149
|
+
"REQUEST_METHOD": _method_name(req.method),
|
|
150
|
+
"SCRIPT_NAME": "",
|
|
151
|
+
# PATH_INFO is the URL-decoded path expressed as a WSGI native string.
|
|
152
|
+
"PATH_INFO": _wsgi_native_string(unquote(url.path or "/")),
|
|
153
|
+
# QUERY_STRING stays percent-encoded per the spec.
|
|
154
|
+
"QUERY_STRING": url.query,
|
|
155
|
+
# A request URL is always absolute here, so hostname is populated; keep a
|
|
156
|
+
# fallback since WSGI requires SERVER_NAME to be non-empty.
|
|
157
|
+
"SERVER_NAME": url.hostname,
|
|
158
|
+
"SERVER_PORT": _server_port(url),
|
|
159
|
+
"SERVER_PROTOCOL": "HTTP/1.1",
|
|
160
|
+
"wsgi.version": (1, 0),
|
|
161
|
+
"wsgi.url_scheme": url.scheme,
|
|
162
|
+
"wsgi.input": wsgi_input,
|
|
163
|
+
"wsgi.errors": sys.stderr,
|
|
164
|
+
"wsgi.multithread": False,
|
|
165
|
+
"wsgi.multiprocess": False,
|
|
166
|
+
"wsgi.run_once": False,
|
|
167
|
+
# Cloudflare-specific extension so handlers can reach bindings, mirroring
|
|
168
|
+
# the `scope["env"]` convention used by asgi.py.
|
|
169
|
+
"workers.env": env,
|
|
170
|
+
}
|
|
171
|
+
environ.update(_header_variables(request_headers(req)))
|
|
172
|
+
|
|
173
|
+
# Only synthesize CONTENT_LENGTH when the body was pre-buffered; when
|
|
174
|
+
# streaming lazily we don't know the length without consuming it.
|
|
175
|
+
if "CONTENT_LENGTH" not in environ and content_length_fallback:
|
|
176
|
+
environ["CONTENT_LENGTH"] = str(content_length_fallback)
|
|
177
|
+
|
|
178
|
+
return environ
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _method_name(method: Any) -> str:
|
|
182
|
+
# `workers.Request.method` is an `http.HTTPMethod` (a str subclass); coerce
|
|
183
|
+
# to a plain string so frameworks comparing against literals behave.
|
|
184
|
+
return str(method.value) if hasattr(method, "value") else str(method)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _server_port(url: SplitResult) -> str:
|
|
188
|
+
if url.port:
|
|
189
|
+
return str(url.port)
|
|
190
|
+
return "443" if url.scheme == "https" else "80"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _header_variables(headers: Iterable[tuple[str, str]]) -> dict[str, str]:
|
|
194
|
+
"""The environ variables for request headers, per the CGI convention."""
|
|
195
|
+
variables: dict[str, str] = {}
|
|
196
|
+
for key, value in headers:
|
|
197
|
+
name = key.upper().replace("-", "_")
|
|
198
|
+
if name in ("CONTENT_TYPE", "CONTENT_LENGTH"):
|
|
199
|
+
variables[name] = value
|
|
200
|
+
elif (http_name := "HTTP_" + name) in variables:
|
|
201
|
+
# Repeated headers are folded into a single comma-separated value.
|
|
202
|
+
variables[http_name] += "," + value
|
|
203
|
+
else:
|
|
204
|
+
variables[http_name] = value
|
|
205
|
+
|
|
206
|
+
return variables
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _to_js_uint8array(chunk: bytes) -> "js.Uint8Array":
|
|
210
|
+
"""Copy Python ``bytes`` into a standalone, JS-owned ``Uint8Array``.
|
|
211
|
+
|
|
212
|
+
A copy (rather than a view over the WASM heap) is required because
|
|
213
|
+
``ReadableStreamDefaultController.enqueue`` keeps the chunk by reference in
|
|
214
|
+
the stream's internal queue until the consumer reads it later, by which
|
|
215
|
+
point ``chunk`` has been released. ``to_js`` copies buffer data out of the
|
|
216
|
+
heap into a fresh ``ArrayBuffer`` (pyodide's ``python2js_buffer``), so its
|
|
217
|
+
result stays valid.
|
|
218
|
+
"""
|
|
219
|
+
from pyodide.ffi import to_js
|
|
220
|
+
|
|
221
|
+
return to_js(chunk)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _close_iterable(iterable: Any) -> None:
|
|
225
|
+
# PEP 3333: if the iterable has a close() method, the server must call it.
|
|
226
|
+
close = getattr(iterable, "close", None)
|
|
227
|
+
if close is not None:
|
|
228
|
+
close()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# Sentinel marking exhaustion of the response body iterator.
|
|
232
|
+
_END = object()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _make_streaming_response(
|
|
236
|
+
status: str,
|
|
237
|
+
headers: "list[tuple[str, str]]",
|
|
238
|
+
chunks: "Iterator[bytes]",
|
|
239
|
+
on_close: "Callable[[], None]",
|
|
240
|
+
) -> js.Response:
|
|
241
|
+
"""Build a ``js.Response`` whose body is pulled lazily from *chunks*.
|
|
242
|
+
|
|
243
|
+
Uses a ``ReadableStream`` with a synchronous, pull-based source so response
|
|
244
|
+
bytes are produced on demand as the client reads, instead of buffering the
|
|
245
|
+
whole body in memory. ``on_close`` runs exactly once when the stream is
|
|
246
|
+
exhausted, errored, or cancelled.
|
|
247
|
+
"""
|
|
248
|
+
from js import ReadableStream, Response
|
|
249
|
+
|
|
250
|
+
init = _response_init(status, headers)
|
|
251
|
+
if init["status"] in NULL_BODY_STATUSES:
|
|
252
|
+
on_close()
|
|
253
|
+
return Response.new(None, **init)
|
|
254
|
+
|
|
255
|
+
source = _PullSource(chunks, on_close)
|
|
256
|
+
stream = ReadableStream.new(pull=source.pull, cancel=source.cancel)
|
|
257
|
+
|
|
258
|
+
return Response.new(stream, **init)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _response_init(status: str, headers: "list[tuple[str, str]]") -> dict[str, Any]:
|
|
262
|
+
from js import Headers as JsHeaders
|
|
263
|
+
|
|
264
|
+
# WSGI status is e.g. "200 OK"; split into code + reason phrase.
|
|
265
|
+
code, _, reason = status.partition(" ")
|
|
266
|
+
init: dict[str, Any] = {"status": int(code), **body_encoding(headers)}
|
|
267
|
+
if reason:
|
|
268
|
+
init["statusText"] = reason
|
|
269
|
+
|
|
270
|
+
init["headers"] = JsHeaders.new()
|
|
271
|
+
for key, value in headers:
|
|
272
|
+
# `append` (not `set`) preserves repeated headers such as Set-Cookie.
|
|
273
|
+
init["headers"].append(key, value)
|
|
274
|
+
|
|
275
|
+
return init
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class _PullSource:
|
|
279
|
+
"""The pull-based source of a response ``ReadableStream``."""
|
|
280
|
+
|
|
281
|
+
def __init__(self, chunks: "Iterator[bytes]", on_close: "Callable[[], None]"):
|
|
282
|
+
from pyodide.ffi import create_proxy
|
|
283
|
+
|
|
284
|
+
self._chunks = chunks
|
|
285
|
+
self._on_close = on_close
|
|
286
|
+
self._done = False
|
|
287
|
+
# Make proxies async so that it is possible to stack switch inside them.
|
|
288
|
+
self.pull = create_proxy(self._pull)
|
|
289
|
+
self.cancel = create_proxy(self._cancel)
|
|
290
|
+
|
|
291
|
+
async def _pull(self, controller: Any) -> None:
|
|
292
|
+
try:
|
|
293
|
+
chunk: Any = next(self._chunks, _END)
|
|
294
|
+
except Exception as exc:
|
|
295
|
+
logger.exception("Exception while streaming WSGI response body")
|
|
296
|
+
self._cleanup()
|
|
297
|
+
controller.error(str(exc))
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
if chunk is _END:
|
|
301
|
+
controller.close()
|
|
302
|
+
self._cleanup()
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
controller.enqueue(_to_js_uint8array(chunk))
|
|
306
|
+
|
|
307
|
+
async def _cancel(self, _reason: Any = None) -> None:
|
|
308
|
+
self._cleanup()
|
|
309
|
+
|
|
310
|
+
def _cleanup(self) -> None:
|
|
311
|
+
if self._done:
|
|
312
|
+
return
|
|
313
|
+
|
|
314
|
+
self._done = True
|
|
315
|
+
try:
|
|
316
|
+
self._on_close()
|
|
317
|
+
finally:
|
|
318
|
+
self.pull.destroy()
|
|
319
|
+
self.cancel.destroy()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class _StartResponse:
|
|
323
|
+
"""The ``start_response`` callable, and the status and headers it records."""
|
|
324
|
+
|
|
325
|
+
def __init__(self) -> None:
|
|
326
|
+
self.status: str | None = None
|
|
327
|
+
self.headers: list[tuple[str, str]] = []
|
|
328
|
+
# Output from the (rarely used) legacy `write()` callable. Modern
|
|
329
|
+
# frameworks (Flask, Werkzeug, Django) return an iterable instead; when
|
|
330
|
+
# they do use write(), that output is emitted before the iterable's
|
|
331
|
+
# chunks.
|
|
332
|
+
self.written: list[bytes] = []
|
|
333
|
+
|
|
334
|
+
def __call__(self, status, response_headers, exc_info=None):
|
|
335
|
+
if exc_info is not None:
|
|
336
|
+
try:
|
|
337
|
+
if self.status is not None:
|
|
338
|
+
# Headers were already sent; re-raise the original error.
|
|
339
|
+
raise exc_info[1].with_traceback(exc_info[2])
|
|
340
|
+
finally:
|
|
341
|
+
exc_info = None
|
|
342
|
+
elif self.status is not None:
|
|
343
|
+
raise AssertionError("start_response() called more than once")
|
|
344
|
+
|
|
345
|
+
self.status = status
|
|
346
|
+
self.headers = response_headers
|
|
347
|
+
return self.write
|
|
348
|
+
|
|
349
|
+
def write(self, chunk: bytes) -> None:
|
|
350
|
+
if self.status is None:
|
|
351
|
+
raise AssertionError("write() called before start_response()")
|
|
352
|
+
if chunk:
|
|
353
|
+
self.written.append(chunk)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def process_request(
|
|
357
|
+
app: Any,
|
|
358
|
+
req: "Request | js.Request",
|
|
359
|
+
env: Any,
|
|
360
|
+
body: "bytes | io.IOBase",
|
|
361
|
+
) -> js.Response:
|
|
362
|
+
environ = build_environ(req, env, body)
|
|
363
|
+
start_response = _StartResponse()
|
|
364
|
+
result = app(environ, start_response)
|
|
365
|
+
result_iter = filter(None, iter(result))
|
|
366
|
+
|
|
367
|
+
def close_all() -> None:
|
|
368
|
+
_close_iterable(result)
|
|
369
|
+
try:
|
|
370
|
+
environ["wsgi.input"].close()
|
|
371
|
+
except Exception:
|
|
372
|
+
logger.exception("Failed to close wsgi.input")
|
|
373
|
+
|
|
374
|
+
# WSGI apps must call start_response before yielding the first body chunk,
|
|
375
|
+
# but some defer it until the first non-empty chunk is produced. Pull that
|
|
376
|
+
# chunk now so status/headers are known before we construct the Response.
|
|
377
|
+
try:
|
|
378
|
+
first_chunk: Any = next(result_iter, _END)
|
|
379
|
+
except Exception:
|
|
380
|
+
close_all()
|
|
381
|
+
raise
|
|
382
|
+
|
|
383
|
+
if start_response.status is None:
|
|
384
|
+
close_all()
|
|
385
|
+
raise RuntimeError("The WSGI application did not call start_response()")
|
|
386
|
+
|
|
387
|
+
def body_chunks() -> "Iterator[bytes]":
|
|
388
|
+
yield from start_response.written
|
|
389
|
+
if first_chunk is not _END:
|
|
390
|
+
yield first_chunk
|
|
391
|
+
yield from result_iter
|
|
392
|
+
|
|
393
|
+
return _make_streaming_response(
|
|
394
|
+
start_response.status, start_response.headers, body_chunks(), close_all
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def fetch(
|
|
399
|
+
app: Any,
|
|
400
|
+
req: "Request | js.Request",
|
|
401
|
+
env: Any,
|
|
402
|
+
# Accepted for parity with asgi.fetch; WSGI has no use for it.
|
|
403
|
+
ctx: "Context | ExecutionContext | None" = None,
|
|
404
|
+
) -> js.Response:
|
|
405
|
+
logger.debug("WSGI request: %s %s", req.method, req.url)
|
|
406
|
+
# Prefer lazily streaming the body through `wsgi.input` (no full buffering);
|
|
407
|
+
# fall back to pre-buffering when `run_sync`/JSPI isn't available.
|
|
408
|
+
body: bytes | io.IOBase | None = _make_wsgi_input(req)
|
|
409
|
+
if body is None:
|
|
410
|
+
body = await _read_body(req)
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
return process_request(app, req, env, body)
|
|
414
|
+
except Exception:
|
|
415
|
+
logger.exception("WSGI request failed")
|
|
416
|
+
raise
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def entrypoint(app: Any) -> type[WorkerEntrypoint]:
|
|
420
|
+
"""Create the default Worker entrypoint for a WSGI application."""
|
|
421
|
+
|
|
422
|
+
class Default(WorkerEntrypoint):
|
|
423
|
+
async def fetch(self, request):
|
|
424
|
+
return await fetch(app, request, self.env)
|
|
425
|
+
|
|
426
|
+
return Default
|