Skip to content
File

Blob: src/pyodide/internal/workers-api/src/workers/_workers.py

python1407 lines
1# This module defines a Workers API for Python. It is similar to the API provided by
2# JS Workers, but with changes and additions to be more idiomatic to the Python
3# programming language.
4import datetime
5import functools
6import inspect
7import json
8from asyncio import create_task, gather
9from collections.abc import (
10 Awaitable,
11 Generator,
12 Iterable,
13 Iterator,
14 MutableMapping,
15 Sequence,
16)
17from contextlib import ExitStack, contextmanager
18from enum import StrEnum
19from http import HTTPMethod, HTTPStatus
20from types import LambdaType
21from typing import Any, Never, Protocol, TypedDict, Unpack
22 
23import _cloudflare_compat_flags
24 
25# Get globals modules and import function from the entrypoint-helper
26import _pyodide_entrypoint_helper
27import js
28from js import Object
29 
30import pyodide.http
31from pyodide import __version__ as pyodide_version
32from pyodide.ffi import (
33 JsBuffer,
34 JsException,
35 JsProxy,
36 create_proxy,
37 destroy_proxies,
38 to_js,
39)
40from pyodide.http import pyfetch
41from workers.workflows import NonRetryableError
42 
43 
44class Context(Protocol):
45 def waitUntil(self, other: Awaitable[Any]) -> None: ...
46 
47 
48try:
49 from pyodide.ffi import jsnull
50except ImportError:
51 jsnull = None
52 
53 
54def _jsnull_to_none(x):
55 if x is jsnull:
56 return None
57 return x
58 
59 
60def import_from_javascript(module_name: str) -> Any:
61 """
62 Import a JavaScript ES module from Python.
63
64 Args:
65 module_name: The name of the module to import. This can be a module name or a path.
66
67 Returns:
68 The imported module object.
69
70 Example:
71 cloudflare_workers = import_from_javascript("cloudflare:workers")
72 env = cloudflare_workers.env
73
74 Note:
75 Behind the scenes import_from_javascript uses JSPI to do imports but that means we need an
76 async context. To enable importing cloudflare:workers and cloudflare:sockets in the global
77 scope we specifically imported them in the global scope and exposed them here.
78 """
79 # Special case for global scope available modules
80 # JSPI won't work in the global scope in 0.26.0a2 so we need modules importable in the global
81 # scope to be imported beforehand.
82 if module_name == "cloudflare:workers":
83 return _pyodide_entrypoint_helper.cloudflareWorkersModule
84 elif module_name == "cloudflare:sockets":
85 return _pyodide_entrypoint_helper.cloudflareSocketsModule
86 
87 try:
88 from pyodide.ffi import run_sync
89 
90 # Call the JavaScript import function
91 return run_sync(_pyodide_entrypoint_helper.doAnImport(module_name))
92 except JsException as e:
93 raise ImportError(f"Failed to import '{module_name}': {e}") from e
94 except RuntimeError as e:
95 if e.args[0] == "No suspender":
96 raise ImportError(
97 f"Failed to import '{module_name}': Only 'cloudflare:workers' and 'cloudflare:sockets' are available in the global scope."
98 ) from e
99 raise
100 except ImportError as e:
101 if e.args[0].startswith("cannot import name 'run_sync' from 'pyodide.ffi'"):
102 raise ImportError(
103 f"Failed to import '{module_name}': Only 'cloudflare:workers' and 'cloudflare:sockets' are available until the next python runtime version."
104 ) from e
105 raise
106 
107 
108@contextmanager
109def patch_env(
110 d: dict[str, Any] | Sequence[tuple[str, Any]] | None = None, **kwds: dict[str, Any]
111) -> Iterator[None]:
112 if d:
113 kwds = dict(d) | kwds
114 yield from _pyodide_entrypoint_helper.patch_env_helper(to_js(kwds))
115 
116 
117type JSBody = (
118 "js.Blob | JsBuffer | js.FormData | js.ReadableStream | js.URLSearchParams"
119)
120type Body = "str | FormData | JSBody"
121type Headers = "dict[str, str] | list[tuple[str, str]] | js.Headers"
122 
123 
124# https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties
125class RequestInitCfProperties(TypedDict, total=False):
126 apps: bool | None
127 cacheEverything: bool | None
128 cacheKey: str | None
129 cacheTags: list[str] | None
130 cacheTtl: int
131 cacheTtlByStatus: dict[str, int]
132 image: (
133 Any | None
134 ) # TODO: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
135 mirage: bool | None
136 polish: str | None
137 resolveOverride: str | None
138 scrapeShield: bool | None
139 webp: bool | None
140 
141 
142# This matches the Request options:
143# https://developers.cloudflare.com/workers/runtime-apis/request/#options
144class FetchKwargs(TypedDict, total=False):
145 headers: "Headers | None"
146 body: "Body | None"
147 method: HTTPMethod | None
148 redirect: str | None
149 cf: RequestInitCfProperties | None
150 fetcher: type[pyfetch] | None
151 
152 
153# TODO: Pyodide's FetchResponse.headers returns a dict[str, str] which means
154# duplicates are lost, we should fix that so it returns a http.client.HTTPMessage
155class FetchResponse(pyodide.http.FetchResponse):
156 # TODO: Consider upstreaming the `body` attribute
157 # TODO: Behind a compat flag make this return a native stream (StreamReader?), or perhaps
158 # behind a different name, maybe `stream`?
159 @property
160 def body(self) -> "js.ReadableStream":
161 """
162 Returns the body as a JavaScript ReadableStream from the JavaScript Response instance.
163 """
164 return _jsnull_to_none(self.js_response.body)
165 
166 @property
167 def js_object(self) -> "js.Response":
168 return self.js_response
169 
170 """
171 Instance methods defined below.
172
173 Some methods are implemented by `FetchResponse`, these include `buffer`
174 (replacing JavaScript's `arrayBuffer`), `bytes`, `json`, and `text`.
175
176 There are also some additional methods implemented by `FetchResponse`.
177 See https://pyodide.org/en/stable/usage/api/python-api/http.html#pyodide.http.FetchResponse
178 for details.
179 """
180 
181 async def formData(self) -> "FormData": # TODO: Remove after certain compat date.
182 return await self.form_data()
183 
184 async def form_data(self) -> "FormData":
185 self._raise_if_failed()
186 try:
187 return FormData(await self.js_response.formData())
188 except JsException as exc:
189 raise _to_python_exception(exc) from exc
190 
191 def replace_body(self, body: Body) -> "Response":
192 """
193 Returns a new Response object with the same options (status, headers, etc) as
194 the original but with an updated body.
195 """
196 b = body.js_object if isinstance(body, FormData) else body
197 js_resp = js.Response.new(b, self.js_response)
198 return Response(js_resp)
199 
200 async def blob(self) -> "Blob":
201 self._raise_if_failed()
202 return Blob(await self.js_object.blob())
203 
204 """
205 Static methods defined below. The `error` static method is not implemented as
206 it is not useful for the Workers use case.
207 """
208 
209 @staticmethod
210 def redirect(url: str, status: HTTPStatus | int = HTTPStatus.FOUND):
211 code = status.value if isinstance(status, HTTPStatus) else status
212 try:
213 return js.Response.redirect(url, code)
214 except JsException as exc:
215 raise _to_python_exception(exc) from exc
216 
217 @staticmethod
218 def from_json(
219 data: str | dict[str, Any] | list[Any] | JsProxy,
220 status: HTTPStatus | int = HTTPStatus.OK,
221 status_text="",
222 headers: Headers = None,
223 ) -> "Response":
224 options = Response._create_options(status, status_text, headers)
225 js_resp = None
226 try:
227 if isinstance(data, JsProxy):
228 js_resp = js.Response.json(data, **options)
229 else:
230 if "headers" not in options:
231 options["headers"] = _to_js_headers(
232 {"content-type": "application/json"}
233 )
234 elif not options["headers"].has("content-type"):
235 options["headers"].set("content-type", "application/json")
236 js_resp = js.Response.new(json.dumps(data), **options)
237 except JsException as exc:
238 raise _to_python_exception(exc) from exc
239 
240 return Response(js_resp)
241 
242 def json(self, *args: Never, **kwargs: Never):
243 if isinstance(self, Response):
244 return super().json()
245 # For compatibility, allow static use of Response.json() to mean Response.from_json().
246 data = self
247 return Response.from_json(data, *args, **kwargs)
248 
249 
250if pyodide_version == "0.26.0a2":
251 
252 async def _pyfetch_patched(
253 request: "str | js.Request", **kwargs: Any
254 ) -> "Response":
255 # This is copied from https://github.com/pyodide/pyodide/blob/d3f99e1d/src/py/pyodide/http.py
256 custom_fetch = kwargs["fetcher"] if "fetcher" in kwargs else js.fetch
257 kwargs["fetcher"] = None
258 try:
259 return Response(
260 await custom_fetch(
261 request, to_js(kwargs, dict_converter=Object.fromEntries)
262 ),
263 )
264 except JsException as e:
265 raise OSError(e.message) from None
266else:
267 _pyfetch_patched = pyfetch
268 
269 
270async def fetch(
271 resource: "str | Request | js.Request",
272 **other_options: Unpack[FetchKwargs],
273) -> "Response":
274 if isinstance(resource, Request):
275 resource = resource.js_object
276 if "method" in other_options and isinstance(other_options["method"], HTTPMethod):
277 other_options["method"] = other_options["method"].value
278 
279 resp = await _pyfetch_patched(resource, **other_options)
280 return Response(resp.js_response)
281 
282 
283def _to_python_exception(exc: JsException) -> Exception:
284 if exc.name == "RangeError":
285 return ValueError(exc.message)
286 elif exc.name == "TypeError":
287 return TypeError(exc.message)
288 else:
289 return exc
290 
291 
292def _from_js_error(exc: JsException) -> Exception:
293 # convert into Python exception after a full round trip
294 # Python - JS - Python
295 if not exc.message or not exc.message.startswith("PythonError"):
296 return _to_python_exception(exc)
297 
298 # extract the Python exception type from the traceback
299 error_message_last_line = exc.message.split("\n")[-2]
300 if error_message_last_line.startswith("TypeError"):
301 return TypeError(error_message_last_line)
302 elif error_message_last_line.startswith("ValueError"):
303 return ValueError(error_message_last_line)
304 elif error_message_last_line.startswith("workers.workflows.NonRetryableError"):
305 return NonRetryableError(error_message_last_line)
306 else:
307 return _to_python_exception(exc)
308 
309 
310@contextmanager
311def _manage_pyproxies():
312 proxies = js.Array.new()
313 try:
314 yield proxies
315 finally:
316 destroy_proxies(proxies)
317 
318 
319def _is_js_instance(val, js_cls_name):
320 return hasattr(val, "constructor") and val.constructor.name == js_cls_name
321 
322 
323try:
324 import _cloudflare_compat_flags
325except ImportError:
326 _cloudflare_compat_flags = object()
327 
328 
329def get_compat_flag(flag: str) -> bool:
330 return getattr(_cloudflare_compat_flags, flag, False)
331 
332 
333def _to_js_headers(headers: Headers):
334 if isinstance(headers, list):
335 # We should have a list[tuple[str, str]]
336 return js.Headers.new(headers)
337 elif isinstance(headers, dict):
338 return js.Headers.new(headers.items())
339 elif _is_js_instance(headers, "Headers"):
340 return headers
341 else:
342 raise TypeError("Received unexpected type for headers argument")
343 
344 
345@contextmanager
346def _get_js_body(body):
347 if isinstance(body, bytes):
348 proxy_bytes = create_proxy(body)
349 proxy_buffer = proxy_bytes.getBuffer()
350 try:
351 yield proxy_buffer.data
352 return
353 finally:
354 proxy_buffer.release()
355 proxy_bytes.destroy()
356 if isinstance(body, FormData):
357 yield body.js_object
358 return
359 yield body
360 
361 
362RESPONSE_ACCEPTED_TYPES = {
363 # BufferSource types
364 "Blob",
365 "ArrayBuffer",
366 "TypedArray",
367 "DataView",
368 "Uint8Array",
369 "Uint8ClampedArray",
370 "Int8Array",
371 "Uint16Array",
372 "Int16Array",
373 "Uint32Array",
374 "Int32Array",
375 "Float16Array",
376 "Float32Array",
377 "Float64Array",
378 "BigInt64Array",
379 "BigUint64Array",
380 # Other types
381 "FormData",
382 "ReadableStream",
383 "URLSearchParams",
384 "Response",
385}
386 
387 
388class Response(FetchResponse):
389 """
390 This class represents the response to an HTTP request, with a similar API to that of the web
391 `Response` API: https://developer.mozilla.org/en-US/docs/Web/API/Response.
392 """
393 
394 def __init__(
395 self,
396 body: Body = None,
397 status: HTTPStatus | int | None = None,
398 status_text="",
399 headers: Headers = None,
400 web_socket: "js.WebSocket | None" = None,
401 ):
402 """
403 Represents the response to a request.
404
405 Based on the JS API of the same name:
406 https://developer.mozilla.org/en-US/docs/Web/API/Response/Response.
407 """
408 # Verify passed in types.
409 if hasattr(body, "constructor"):
410 if body.constructor.name not in RESPONSE_ACCEPTED_TYPES:
411 raise TypeError(
412 f"Unsupported type in Response: {body.constructor.name}"
413 )
414 elif not isinstance(body, str | FormData | bytes) and body is not None:
415 raise TypeError(f"Unsupported type in Response: {type(body).__name__}")
416 
417 # Handle constructing a Response from a JS Response.
418 if _is_js_instance(body, "Response"):
419 if status is not None or len(status_text) > 0 or headers is not None:
420 raise ValueError(
421 "Expected no options when constructing Response from a js.Response"
422 )
423 super().__init__(body.url, body)
424 return
425 
426 options = self._create_options(status, status_text, headers, web_socket)
427 
428 # To avoid unnecessary copies we use this context manager.
429 with _get_js_body(body) as js_body:
430 # Initialize via the FetchResponse super-class which gives us access to
431 # methods that we would ordinarily have to redeclare.
432 js_resp = js.Response.new(js_body, **options)
433 super().__init__(js_resp.url, js_resp)
434 
435 def __repr__(self):
436 body = [f"status={self.status}"]
437 if self.js_object.statusText:
438 body.append(f"status_text={self.status_text!r}")
439 if "content-type" in self.headers:
440 body.append(f"content_type={self.headers['content-type']!r}")
441 if self.js_object.url:
442 body.append(f"url={self.js_object.url!r}")
443 if self.js_object.type != "default":
444 body.append(f"type={self.js_object.type!r}")
445 return f"Response({', '.join(body)})"
446 
447 @staticmethod
448 def _create_options(
449 status: HTTPStatus | int | None = HTTPStatus.OK,
450 status_text="",
451 headers: Headers = None,
452 web_socket: "js.WebSocket | None" = None,
453 ):
454 options = {}
455 if status:
456 options["status"] = (
457 status.value if isinstance(status, HTTPStatus) else status
458 )
459 if status_text:
460 options["statusText"] = status_text
461 if headers:
462 options["headers"] = _to_js_headers(headers)
463 if web_socket:
464 options["webSocket"] = web_socket
465 return options
466 
467 
468FormDataValue = "str | js.Blob | Blob"
469 
470 
471def _py_value_to_js(item: FormDataValue) -> "str | js.Blob":
472 if isinstance(item, Blob):
473 return item.js_object
474 else:
475 return item
476 
477 
478def _js_value_to_py(item: FormDataValue) -> "str | Blob | File":
479 if hasattr(item, "constructor") and (item.constructor.name in ("Blob", "File")):
480 if item.constructor.name == "File":
481 return File(item, item.name)
482 else:
483 return Blob(item)
484 else:
485 return item
486 
487 
488class FormData(MutableMapping[str, FormDataValue]):
489 """
490 This class represents a set of key/value pairs for forms.
491
492 The API of this class follows that of https://pypi.org/project/multidict/ and
493 https://developer.mozilla.org/en-US/docs/Web/API/FormData.
494 """
495 
496 def __init__(
497 self, form_data: "js.FormData | None | dict[str, FormDataValue]" = None
498 ):
499 if not form_data:
500 self._js_form_data = js.FormData.new()
501 return
502 
503 if isinstance(form_data, dict):
504 self._js_form_data = js.FormData.new()
505 for k, v in form_data.items():
506 self._js_form_data.append(k, _py_value_to_js(v))
507 return
508 
509 if _is_js_instance(form_data, "FormData"):
510 self._js_form_data = form_data
511 return
512 
513 raise TypeError("Expected form_data to be a dict or an instance of FormData")
514 
515 def __getitem__(self, key: str) -> FormDataValue:
516 return _js_value_to_py(self._js_form_data.get(key))
517 
518 def __setitem__(self, key: str, value: FormDataValue):
519 if isinstance(value, list):
520 raise TypeError("Expected single item in arguments to FormData.__setitem__")
521 self._js_form_data.set(key, _py_value_to_js(value))
522 
523 def append(self, key: str, value: FormDataValue, filename: str | None = None):
524 self._js_form_data.append(key, _py_value_to_js(value), filename)
525 
526 def delete(self, key: str):
527 self._js_form_data.delete(key)
528 
529 def __contains__(self, key: str) -> bool:
530 return self._js_form_data.has(key)
531 
532 def values(self) -> Generator[FormDataValue, None, None]:
533 for val in self._js_form_data.values():
534 yield _js_value_to_py(val)
535 
536 def keys(self) -> Generator[str, None, None]:
537 yield from self._js_form_data.keys()
538 
539 def __iter__(self):
540 yield from self.keys()
541 
542 def items(self) -> Generator[tuple[str, FormDataValue], None, None]:
543 for k, v in self._js_form_data.entries():
544 yield (k, _js_value_to_py(v))
545 
546 def __delitem__(self, key: str):
547 self.delete(key)
548 
549 def __len__(self):
550 return len(self.keys())
551 
552 def get_all(self, key: str) -> list[FormDataValue]:
553 return [_js_value_to_py(x) for x in self._js_form_data.getAll(key)]
554 
555 @property
556 def js_object(self) -> "js.FormData":
557 return self._js_form_data
558 
559 
560def _supports_buffer_protocol(o):
561 try:
562 # memoryview used only for testing type; 'with' releases the view instantly
563 with memoryview(o):
564 return True
565 except TypeError:
566 return False
567 
568 
569@contextmanager
570def _make_blob_entry(e):
571 if isinstance(e, str):
572 yield e
573 return
574 if isinstance(e, Blob):
575 yield e._js_blob
576 return
577 if hasattr(e, "constructor") and (e.constructor.name in ("Blob", "File")):
578 yield e
579 return
580 if _supports_buffer_protocol(e):
581 px = create_proxy(e)
582 buf = px.getBuffer()
583 try:
584 yield buf.data
585 return
586 finally:
587 buf.release()
588 px.destroy()
589 raise TypeError(f"Don't know how to handle {type(e)} for Blob()")
590 
591 
592def _is_iterable(obj):
593 if isinstance(obj, (str, bytes)):
594 return False
595 try:
596 iter(obj)
597 except TypeError:
598 return False
599 else:
600 return True
601 
602 
603BlobValue = (
604 "str | bytes | js.ArrayBuffer | js.TypedArray | js.DataView | js.Blob | Blob | File"
605)
606 
607 
608class BlobEnding(StrEnum):
609 TRANSPARENT = "transparent"
610 NATIVE = "native"
611 
612 
613class Blob:
614 def __init__(
615 self,
616 blob_parts: "Iterable[BlobValue] | BlobValue",
617 content_type: str | None = None,
618 endings: BlobEnding | str | None = None,
619 ):
620 if endings:
621 endings = str(endings)
622 
623 is_single_item = not _is_iterable(blob_parts)
624 if is_single_item:
625 # Inherit the content_type if we have a single item. If a File is passed
626 # in then its metadata is lost.
627 if not content_type and isinstance(blob_parts, Blob):
628 content_type = blob_parts.content_type
629 if hasattr(blob_parts, "constructor") and (
630 blob_parts.constructor.name in ("Blob", "File")
631 ):
632 if not content_type:
633 content_type = blob_parts.type
634 
635 # Otherwise create a new Blob below.
636 blob_parts = [blob_parts]
637 
638 with ExitStack() as stack:
639 args = [stack.enter_context(_make_blob_entry(e)) for e in blob_parts]
640 with _manage_pyproxies() as pyproxies:
641 self._js_blob = js.Blob.new(
642 to_js(args, pyproxies=pyproxies),
643 type=content_type,
644 endings=endings,
645 )
646 
647 @property
648 def size(self) -> int:
649 return self._js_blob.size
650 
651 @property
652 def content_type(self) -> str:
653 return self._js_blob.type
654 
655 @property
656 def js_object(self) -> "js.Blob":
657 return self._js_blob
658 
659 async def text(self) -> str:
660 return await self.js_object.text()
661 
662 async def bytes(self) -> bytes:
663 return (await self.js_object.arrayBuffer()).to_bytes()
664 
665 def slice(
666 self,
667 start: int | None = None,
668 end: int | None = None,
669 content_type: str | None = None,
670 ):
671 js_sliced_blob = self.js_object.slice(start, end, content_type)
672 return Blob([js_sliced_blob])
673 
674 
675class File(Blob):
676 def __init__(
677 self,
678 blob_parts: "Iterable[BlobValue] | BlobValue",
679 filename: str,
680 content_type: str | None = None,
681 endings: BlobEnding | str | None = None,
682 last_modified: int | None = None,
683 ):
684 if endings:
685 endings = str(endings)
686 
687 is_single_item = not _is_iterable(blob_parts)
688 if is_single_item:
689 # Inherit the content_type and lastModified if we have a
690 # single item.
691 if not content_type and isinstance(blob_parts, Blob):
692 content_type = blob_parts.content_type
693 if not last_modified and isinstance(blob_parts, File):
694 last_modified = blob_parts.last_modified
695 if hasattr(blob_parts, "constructor") and (
696 blob_parts.constructor.name in ("Blob", "File")
697 ):
698 if not content_type:
699 content_type = blob_parts.type
700 if blob_parts.constructor.name == "File":
701 if not last_modified:
702 last_modified = blob_parts.lastModified
703 
704 # Otherwise create a new File below.
705 blob_parts = [blob_parts]
706 
707 with ExitStack() as stack:
708 args = [stack.enter_context(_make_blob_entry(e)) for e in blob_parts]
709 with _manage_pyproxies() as pyproxies:
710 self._js_blob = js.File.new(
711 to_js(args, pyproxies=pyproxies),
712 filename,
713 type=content_type,
714 endings=endings,
715 lastModified=last_modified,
716 )
717 
718 @property
719 def name(self) -> str:
720 return self._js_blob.name
721 
722 @property
723 def last_modified(self) -> int:
724 return self._js_blob.lastModified
725 
726 
727class Request:
728 def __init__(
729 self, input: "Request | str | js.Request", **other_options: Unpack[FetchKwargs]
730 ):
731 if _is_js_instance(input, "Request"):
732 if len(other_options) > 0:
733 raise ValueError(
734 "Expected no options when constructing Request from a js.Request"
735 )
736 self._js_request = input
737 return
738 
739 if "method" in other_options and isinstance(
740 other_options["method"], HTTPMethod
741 ):
742 other_options["method"] = other_options["method"].value
743 
744 if "headers" in other_options:
745 other_options["headers"] = _to_js_headers(other_options["headers"])
746 self._js_request = js.Request.new(
747 input._js_request if isinstance(input, Request) else input, **other_options
748 )
749 
750 def __repr__(self):
751 return (
752 f"Request(method={self._js_request.method!r}, url={self._js_request.url!r})"
753 )
754 
755 @property
756 def js_object(self) -> "js.Request":
757 return self._js_request
758 
759 # TODO: expose `body` as a native Python stream in the future, follow how we define `Response`
760 @property
761 def body(self) -> "js.ReadableStream":
762 return self.js_object.body
763 
764 @property
765 def body_used(self) -> bool:
766 return self.js_object.bodyUsed
767 
768 @property
769 def cache(self) -> str:
770 return self.js_object.cache
771 
772 @property
773 def credentials(self) -> str:
774 return self.js_object.credentials
775 
776 @property
777 def destination(self) -> str:
778 return self.js_object.destination
779 
780 @property
781 def headers(self):
782 # This is imported here because it costs a lot of CPU time when imported at the top-level.
783 # At least it does when we do so in our validator tests, doesn't seem to cause trouble in
784 # production. So as a workaround we do the import here.
785 #
786 # TODO(later): when dedicated snapshots are default we can move this import to the top-level.
787 import http.client
788 
789 result = http.client.HTTPMessage()
790 if not get_compat_flag("python_request_headers_preserve_commas"):
791 for key, val in self.js_object.headers:
792 result[key] = val.strip()
793 
794 return result
795 
796 # With the exception of Set-Cookie, duplicate headers can and are combined with a comma
797 # in the JS Headers API. We do the same when returning the headers to Python.
798 #
799 # See https://httpwg.org/specs/rfc9110.html#rfc.section.5.3.
800 js_headers = self.js_object.headers
801 set_cookie_headers = js_headers.getSetCookie()
802 if set_cookie_headers:
803 for value in set_cookie_headers:
804 result.add_header("Set-Cookie", value.strip())
805 
806 for key, val in js_headers:
807 if key.lower() == "set-cookie":
808 continue
809 result.add_header(key, val.strip())
810 
811 return result
812 
813 @property
814 def integrity(self) -> str:
815 return self.js_object.integrity
816 
817 @property
818 def is_history_navigation(self) -> bool:
819 return self.js_object.isHistoryNavigation
820 
821 @property
822 def keepalive(self) -> bool:
823 return self.js_object.keepalive
824 
825 @property
826 def method(self) -> HTTPMethod:
827 return HTTPMethod[self.js_object.method]
828 
829 @property
830 def mode(self) -> str:
831 return self.js_object.mode
832 
833 @property
834 def redirect(self) -> str:
835 return self.js_object.redirect
836 
837 @property
838 def referrer(self) -> str:
839 return self.js_object.referrer
840 
841 @property
842 def referrer_policy(self) -> str:
843 return self.js_object.referrerPolicy
844 
845 @property
846 def url(self) -> str:
847 return self.js_object.url
848 
849 def _raise_if_failed(self) -> None:
850 # TODO: https://github.com/pyodide/pyodide/blob/a53c17fd8/src/py/pyodide/http.py#L252
851 if self.body_used:
852 # TODO: Use BodyUsedError in newer Pyodide versions.
853 raise OSError("Body already used")
854 
855 """
856 Instance methods defined below.
857
858 The naming of these methods should match Request's methods when possible.
859
860 TODO: AbortController support.
861 """
862 
863 async def buffer(self) -> "js.ArrayBuffer":
864 # The naming of this method matches that of Response.
865 self._raise_if_failed()
866 return await self.js_object.arrayBuffer()
867 
868 async def form_data(self) -> "FormData":
869 self._raise_if_failed()
870 try:
871 return FormData(await self.js_object.formData())
872 except JsException as exc:
873 raise _to_python_exception(exc) from exc
874 
875 async def blob(self) -> Blob:
876 self._raise_if_failed()
877 return Blob(await self.js_object.blob())
878 
879 async def bytes(self) -> bytes:
880 self._raise_if_failed()
881 return (await self.buffer()).to_bytes()
882 
883 def clone(self) -> "Request":
884 if self.body_used:
885 # TODO: Use BodyUsedError in newer Pyodide versions.
886 raise OSError("Body already used")
887 return Request(
888 self.js_object.clone(),
889 )
890 
891 async def json(self, **kwargs: Any) -> Any:
892 self._raise_if_failed()
893 return json.loads(await self.text(), **kwargs)
894 
895 async def text(self) -> str:
896 self._raise_if_failed()
897 return await self.js_object.text()
898 
899 
900def _python_from_rpc_default_converter(value, convert, cache):
901 if not hasattr(value, "constructor"):
902 # Assume that the object doesn't need conversion as it's not a JS object.
903 return value
904 
905 if value.constructor.name == "Response":
906 return Response(value)
907 elif value.constructor.name == "FormData":
908 return FormData(value)
909 elif value.constructor.name == "Blob":
910 return Blob(value)
911 elif value.constructor.name == "File":
912 return File(value)
913 elif value.constructor.name == "Request":
914 return Request(value)
915 elif value.constructor.name == "Date":
916 # TODO: Pyodide should gain support for this, we should upstream this.
917 return datetime.datetime.fromtimestamp(value.getTime() / 1000)
918 elif value.constructor.name == "Error":
919 return Exception(value.toString())
920 elif value.constructor.name == "Number":
921 return value.valueOf()
922 
923 # We used to throw an error here, but since these conversions are now automatic when the default
924 # entrypoint is being used, it makes sense to be less loud about it and just pass through the
925 # JS value un-modified.
926 #
927 # This does mean that in the future we need to be careful when adding type wrappers for new
928 # types here, so if you're doing this make sure to do so behind a compat flag.
929 return value
930 
931 
932def python_from_rpc(obj: "JsProxy"):
933 """
934 Converts JS objects like Response, Request, Blob, etc. to equivalent Python objects defined in
935 this module and also other JS objects like Map, Set, etc. to equivalent Python stdlib objects.
936
937 This method is used for Workers RPC in Python to convert JavaScript objects to Python. As such
938 it does not support serializing all JS object types.
939 """
940 
941 if not hasattr(obj, "constructor"):
942 return obj
943 
944 if obj.constructor.name == "TestController":
945 # This object currently has no methods defined on it. If this changes we should
946 # implement a Python wrapper for it, but for now we'll just pass in None.
947 return None
948 
949 result = obj.to_py(default_converter=_python_from_rpc_default_converter)
950 
951 return result
952 
953 
954def _raise_on_disabled_type(value):
955 if _is_js_instance(value, "RegExp"):
956 raise TypeError(f"{value.constructor.name} cannot be sent over RPC.")
957 
958 if isinstance(value, (tuple, bytearray, LambdaType)):
959 raise TypeError(f"{type(value)} cannot be sent over RPC.")
960 
961 if inspect.isawaitable(value):
962 # The caller is expected to await the value prior to conversion.
963 raise TypeError(f"Awaitable {type(value)} cannot be sent over RPC.")
964 
965 if _is_iterable(value):
966 if isinstance(value, dict):
967 for v in value.values():
968 _raise_on_disabled_type(v)
969 else:
970 for v in value:
971 _raise_on_disabled_type(v)
972 
973 
974def _python_to_rpc_default_converter(obj, convert, cache):
975 if obj is None:
976 return obj
977 
978 if hasattr(obj, "js_object"):
979 return obj.js_object
980 
981 if isinstance(obj, datetime.datetime):
982 # TODO: Pyodide should gain support for this, we should upstream this.
983 return js.Date.new(obj.timestamp() * 1000)
984 
985 if isinstance(obj, Exception):
986 return js.Error.new(str(obj))
987 
988 _raise_on_disabled_type(obj)
989 
990 return obj
991 
992 
993def python_to_rpc(value) -> JsProxy:
994 """
995 Converts Python objects defined in this module (Response, Request, etc) and native Python types
996 like Map, Set, datetime to equivalent JavaScript types.
997
998 This method is used for Workers RPC in Python to convert Python objects to JavaScript. As such
999 it does not support serializing all Python object types.
1000 """
1001 
1002 # `to_js` won't always call the default_converter, for example when a list of tuples is passed
1003 _raise_on_disabled_type(value)
1004 
1005 result = to_js(
1006 value,
1007 default_converter=_python_to_rpc_default_converter,
1008 dict_converter=js.Map.new,
1009 )
1010 
1011 return result
1012 
1013 
1014class _FetcherWrapper:
1015 def __init__(self, binding):
1016 self._binding = binding
1017 
1018 def _getattr_helper(self, name):
1019 attr = getattr(self._binding, name)
1020 
1021 if not callable(attr):
1022 return attr
1023 
1024 # Not using `@functools.wraps(attr)` here because `attr` is a JS proxy.
1025 async def wrapper(*args, **kwargs):
1026 js_args = [python_to_rpc(arg) for arg in args]
1027 js_kwargs = {k: python_to_rpc(v) for k, v in kwargs.items()}
1028 result = attr(*js_args, **js_kwargs)
1029 if hasattr(result, "then") and callable(result.then):
1030 return python_from_rpc(await result)
1031 else:
1032 return python_from_rpc(result)
1033 
1034 return wrapper
1035 
1036 def __getattr__(self, name):
1037 result = self._getattr_helper(name)
1038 setattr(self, name, result)
1039 return result
1040 
1041 def fetch(self, *args, **kwargs):
1042 return fetch(*args, fetcher=self._binding.fetch, **kwargs)
1043 
1044 
1045class _DurableObjectNamespaceWrapper:
1046 def __init__(self, binding):
1047 self._binding = binding
1048 
1049 def __getattr__(self, name):
1050 return getattr(self._binding, name)
1051 
1052 def get(self, *args, **kwargs):
1053 return _FetcherWrapper(self._binding.get(*args, **kwargs))
1054 
1055 def getByName(self, *args, **kwargs):
1056 return _FetcherWrapper(self._binding.getByName(*args, **kwargs))
1057 
1058 def jurisdiction(self, *args, **kwargs):
1059 return _DurableObjectNamespaceWrapper(
1060 self._binding.jurisdiction(*args, **kwargs)
1061 )
1062 
1063 
1064class _WorkflowInstanceWrapper:
1065 def __init__(self, binding):
1066 self._binding = binding
1067 
1068 def __getattr__(self, name):
1069 return getattr(self._binding, name)
1070 
1071 async def send_event(self, *args, **kwargs):
1072 return self._binding.sendEvent(*args, **kwargs)
1073 
1074 async def pause(self, *args, **kwargs):
1075 return self._binding.pause(*args, **kwargs)
1076 
1077 async def resume(self, *args, **kwargs):
1078 return self._binding.resume(*args, **kwargs)
1079 
1080 async def terminate(self, *args, **kwargs):
1081 return self._binding.terminate(*args, **kwargs)
1082 
1083 async def restart(self, *args, **kwargs):
1084 return self._binding.restart(*args, **kwargs)
1085 
1086 async def status(self, *args, **kwargs):
1087 return self._binding.status(*args, **kwargs)
1088 
1089 
1090class _WorkflowBindingWrapper:
1091 def __init__(self, binding):
1092 self._binding = binding
1093 
1094 def __getattr__(self, name):
1095 return getattr(self._binding, name)
1096 
1097 async def get(self, *args, **kwargs):
1098 return _WorkflowInstanceWrapper(await self._binding.get(*args, **kwargs))
1099 
1100 async def create(self, *args, **kwargs):
1101 return _WorkflowInstanceWrapper(await self._binding.create(*args, **kwargs))
1102 
1103 async def create_batch(self, *args, **kwargs):
1104 return [
1105 _WorkflowInstanceWrapper(w)
1106 for w in await self._binding.createBatch(*args, **kwargs)
1107 ]
1108 
1109 
1110class _EnvWrapper:
1111 def __init__(self, env: Any):
1112 self._env = env
1113 
1114 def _getattr_helper(self, name):
1115 binding = getattr(self._env, name)
1116 if _is_js_instance(binding, "Fetcher"):
1117 return _FetcherWrapper(binding)
1118 
1119 if _is_js_instance(binding, "DurableObjectNamespace"):
1120 return _DurableObjectNamespaceWrapper(binding)
1121 
1122 if _is_js_instance(binding, "WorkflowImpl"):
1123 return _WorkflowBindingWrapper(binding)
1124 
1125 # TODO: Implement APIs for bindings.
1126 return binding
1127 
1128 def __getattr__(self, name):
1129 result = self._getattr_helper(name)
1130 setattr(self, name, result)
1131 return result
1132 
1133 
1134def handler(func):
1135 """
1136 When applied to handlers such as `on_fetch` it will rewrite arguments passed in to native Python
1137 types defined in this module. For example, the `request` argument to `on_fetch` gets converted
1138 to an instance of the Request class defined in this module.
1139 """
1140 
1141 @functools.wraps(func)
1142 def wrapper(*args, **kwargs):
1143 # TODO: support transforming kwargs
1144 if len(args) > 0 and _is_js_instance(args[0], "Request"):
1145 args = (Request(args[0]), *args[1:])
1146 
1147 # Wrap `env` so that bindings can be used without to_js.
1148 if len(args) > 1:
1149 args = (args[0], _EnvWrapper(args[1]), *args[2:])
1150 
1151 return func(*args, **kwargs)
1152 
1153 return wrapper
1154 
1155 
1156class _WorkflowStepWrapper:
1157 def __init__(self, js_step):
1158 self._js_step = js_step
1159 self._memoized_dependencies = {}
1160 self._in_flight = {}
1161 self.step_closures = {}
1162 
1163 # Assign the appropriate method based on compat flag
1164 if _cloudflare_compat_flags.python_workflows_implicit_dependencies:
1165 self.do = self._do_implicit
1166 else:
1167 self.do = self._do_legacy
1168 
1169 def _do_legacy(self, name, depends=None, concurrent=False, config=None):
1170 """Original signature - positional args allowed, explicit depends parameter."""
1171 return self._create_step_decorator(
1172 name=name,
1173 depends=depends,
1174 concurrent=concurrent,
1175 config=config,
1176 implicit=False,
1177 )
1178 
1179 def _do_implicit(self, name=None, *, concurrent=False, config=None):
1180 """New signature - keyword-only args, dependencies resolved from param names."""
1181 return self._create_step_decorator(
1182 name=name,
1183 depends=None,
1184 concurrent=concurrent,
1185 config=config,
1186 implicit=True,
1187 )
1188 
1189 def _create_step_decorator(self, name, depends, concurrent, config, implicit):
1190 """Shared decorator factory for both legacy and implicit modes."""
1191 
1192 def decorator(func):
1193 step_name = func.__name__ if name is None else name
1194 
1195 async def wrapper():
1196 results_future_list = self._build_dependency_list(
1197 func, depends, implicit
1198 )
1199 results = await self._gather_results(results_future_list, concurrent)
1200 return await _do_call(self, step_name, config, func, *results)
1201 
1202 wrapper._step_name = step_name
1203 self.step_closures[step_name] = wrapper
1204 return wrapper
1205 
1206 return decorator
1207 
1208 def _build_dependency_list(self, func, depends, implicit):
1209 """Build the dependency list based on mode (implicit vs legacy)."""
1210 sig = inspect.signature(func)
1211 results_future_list = []
1212 
1213 if implicit:
1214 # Implicit mode: resolve dependencies from parameter names
1215 for p in sig.parameters.values():
1216 if p.name in self.step_closures:
1217 results_future_list.append(self.step_closures[p.name])
1218 elif p.name == "ctx":
1219 results_future_list.append(p)
1220 else:
1221 raise TypeError(f"Received unexpected parameter {p.name}")
1222 else:
1223 # Legacy mode: use explicit depends list, support ctx parameter
1224 non_ctx_params = [p for p in sig.parameters.values() if p.name != "ctx"]
1225 
1226 if depends is None and len(non_ctx_params) > 0:
1227 raise TypeError(
1228 f"Step has {len(non_ctx_params)} non-ctx parameter(s) but no 'depends' list provided"
1229 )
1230 
1231 elif depends is not None and len(depends) != len(non_ctx_params):
1232 raise TypeError(
1233 f"Step declares {len(non_ctx_params)} non-ctx parameter(s) but 'depends' has {len(depends)} item(s)"
1234 )
1235 
1236 curr = 0
1237 for p in sig.parameters.values():
1238 if p.name == "ctx":
1239 results_future_list.append(p)
1240 else:
1241 results_future_list.append(depends[curr])
1242 curr += 1
1243 
1244 return results_future_list
1245 
1246 async def _gather_results(self, results_future_list, concurrent):
1247 """Resolve dependencies concurrently or sequentially."""
1248 if concurrent:
1249 return await gather(
1250 *[self._resolve_dependency(dep) for dep in results_future_list or []]
1251 )
1252 else:
1253 return [
1254 await self._resolve_dependency(dep) for dep in results_future_list or []
1255 ]
1256 
1257 def sleep(self, *args, **kwargs):
1258 return self._js_step.sleep(*args, **kwargs)
1259 
1260 def sleep_until(self, name, timestamp):
1261 if not isinstance(timestamp, str):
1262 timestamp = python_to_rpc(timestamp)
1263 
1264 return self._js_step.sleepUntil(name, timestamp)
1265 
1266 def wait_for_event(self, name, event_type, /, timeout="24 hours"):
1267 return self._js_step.waitForEvent(
1268 name,
1269 to_js(
1270 {"type": event_type, "timeout": timeout},
1271 dict_converter=Object.fromEntries,
1272 ),
1273 )
1274 
1275 async def _resolve_dependency(self, dep):
1276 if hasattr(dep, "name") and dep.name == "ctx":
1277 return dep
1278 elif dep._step_name in self._memoized_dependencies:
1279 return self._memoized_dependencies[dep._step_name]
1280 elif dep._step_name in self._in_flight:
1281 return await self._in_flight[dep._step_name]
1282 
1283 return await dep()
1284 
1285 
1286async def _do_call(entrypoint, name, config, callback, *results):
1287 async def _callback(ctx=None):
1288 # deconstruct the actual ctx object
1289 resolved_results = tuple(
1290 python_from_rpc(ctx)
1291 if isinstance(r, inspect.Parameter) and r.name == "ctx"
1292 else r
1293 for r in results
1294 )
1295 result = callback(*resolved_results)
1296 
1297 if inspect.iscoroutine(result):
1298 result = await result
1299 return to_js(result, dict_converter=Object.fromEntries)
1300 
1301 async def _closure():
1302 try:
1303 if config is None:
1304 coroutine = await entrypoint._js_step.do(name, _callback)
1305 else:
1306 coroutine = await entrypoint._js_step.do(
1307 name, to_js(config, dict_converter=Object.fromEntries), _callback
1308 )
1309 
1310 return python_from_rpc(coroutine)
1311 except Exception as exc:
1312 raise _from_js_error(exc) from exc
1313 
1314 task = create_task(_closure())
1315 entrypoint._in_flight[name] = task
1316 
1317 try:
1318 result = await task
1319 entrypoint._memoized_dependencies[name] = result
1320 finally:
1321 del entrypoint._in_flight[name]
1322 
1323 return result
1324 
1325 
1326def _wrap_subclass(cls):
1327 # Override the class __init__ so that we can wrap the `env` in the constructor.
1328 original_init = cls.__init__
1329 
1330 def wrapped_init(self, *args, **kwargs):
1331 if len(args) > 0:
1332 _pyodide_entrypoint_helper.patchWaitUntil(args[0])
1333 if len(args) > 1:
1334 args = list(args)
1335 args[1] = _EnvWrapper(args[1])
1336 
1337 original_init(self, *args, **kwargs)
1338 
1339 cls.__init__ = wrapped_init
1340 
1341 
1342def _wrap_workflow_step(cls):
1343 run_fn = getattr(cls, "run", None)
1344 if run_fn is None:
1345 return
1346 
1347 # Only patch `on_run` for subclasses of WorkflowEntrypoint.
1348 if not issubclass(cls, WorkflowEntrypoint):
1349 # Not a workflow subclass, so don't wrap `on_run`.
1350 return
1351 
1352 @functools.wraps(run_fn)
1353 async def wrapped_run(self, event=None, step=None, /, *args, **kwargs):
1354 if event is not None:
1355 event = python_from_rpc(event)
1356 if step is not None:
1357 step = _WorkflowStepWrapper(step)
1358 
1359 result = run_fn(self, event, step, *args, **kwargs)
1360 
1361 if inspect.iscoroutine(result):
1362 result = await result
1363 
1364 return result
1365 
1366 cls.run = wrapped_run
1367 
1368 
1369class DurableObject:
1370 """
1371 Base class used to define a Durable Object.
1372 """
1373 
1374 def __init__(self, ctx: Context, env: Any):
1375 self.ctx = ctx
1376 self.env = env
1377 
1378 def __init_subclass__(cls, **_kwargs):
1379 _wrap_subclass(cls)
1380 
1381 
1382class WorkerEntrypoint:
1383 """
1384 Base class used to define a Worker Entrypoint.
1385 """
1386 
1387 def __init__(self, ctx: Context, env: Any):
1388 self.ctx = ctx
1389 self.env = env
1390 
1391 def __init_subclass__(cls, **_kwargs: Any):
1392 _wrap_subclass(cls)
1393 
1394 
1395class WorkflowEntrypoint:
1396 """
1397 Base class used to define a Workflow Entrypoint.
1398 """
1399 
1400 def __init__(self, ctx: Context, env: Any):
1401 self.ctx = ctx
1402 self.env = env
1403 
1404 def __init_subclass__(cls, **_kwargs: Any):
1405 _wrap_subclass(cls)
1406 _wrap_workflow_step(cls)