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.
workers/response.py ADDED
@@ -0,0 +1,181 @@
1
+ import json
2
+ from collections.abc import Callable, Coroutine
3
+ from http import HTTPStatus
4
+ from typing import TYPE_CHECKING, Any, overload
5
+
6
+ import js
7
+ import pyodide.http
8
+ from pyodide.ffi import JsProxy
9
+
10
+ from ._body import BodyMixin
11
+ from ._js import js_body, js_name, none_if_null, python_errors, to_js_headers
12
+ from .blob import Blob
13
+ from .formdata import FormData
14
+
15
+ if TYPE_CHECKING:
16
+ from .types import Body, Headers
17
+
18
+ __all__ = ["RESPONSE_ACCEPTED_TYPES", "FetchResponse", "Response"]
19
+
20
+ # The JS body types js.Response accepts.
21
+ RESPONSE_ACCEPTED_TYPES = {
22
+ "Blob",
23
+ "ArrayBuffer",
24
+ "TypedArray",
25
+ "DataView",
26
+ "Uint8Array",
27
+ "Uint8ClampedArray",
28
+ "Int8Array",
29
+ "Uint16Array",
30
+ "Int16Array",
31
+ "Uint32Array",
32
+ "Int32Array",
33
+ "Float16Array",
34
+ "Float32Array",
35
+ "Float64Array",
36
+ "BigInt64Array",
37
+ "BigUint64Array",
38
+ "FormData",
39
+ "ReadableStream",
40
+ "URLSearchParams",
41
+ "Response",
42
+ }
43
+
44
+
45
+ class _Json:
46
+ """Response.json(data) is from_json; response.json() parses the body.
47
+
48
+ The stock SDK does this inside one method, which a type checker can't
49
+ follow. A descriptor gives each use its own signature.
50
+ """
51
+
52
+ @overload
53
+ def __get__(self, instance: None, owner: type) -> Callable[..., "Response"]: ...
54
+ @overload
55
+ def __get__(
56
+ self, instance: "FetchResponse", owner: type
57
+ ) -> Callable[..., Coroutine[Any, Any, Any]]: ...
58
+ def __get__(self, instance: "FetchResponse | None", owner: type) -> Any:
59
+ if instance is None:
60
+ return _stock_json
61
+ return lambda *args, **kwargs: _stock_json(instance, *args, **kwargs)
62
+
63
+
64
+ def _stock_json(data: Any, *args: Any, **kwargs: Any) -> Any:
65
+ # The stock SDK's one method: a Response's body parse, else from_json.
66
+ if isinstance(data, Response):
67
+ return pyodide.http.FetchResponse.json(data)
68
+ return FetchResponse.from_json(data, *args, **kwargs)
69
+
70
+
71
+ class FetchResponse(BodyMixin, pyodide.http.FetchResponse):
72
+ @property
73
+ def body(self) -> "js.ReadableStream | None":
74
+ return none_if_null(self.js_object.body)
75
+
76
+ @property
77
+ def js_object(self) -> "js.Response":
78
+ return self.js_response
79
+
80
+ async def formData(self) -> FormData: # noqa: N802 (the stock SDK's alias)
81
+ return await self.form_data()
82
+
83
+ def replace_body(self, body: "Body") -> "Response":
84
+ """A new Response with this one's status and headers and a new body."""
85
+ with js_body(body) as converted:
86
+ return Response(js.Response.new(converted, self.js_response))
87
+
88
+ @staticmethod
89
+ def redirect(url: str, status: HTTPStatus | int = HTTPStatus.FOUND) -> "Response":
90
+ # The stock SDK returns the raw JS Response here.
91
+ with python_errors():
92
+ return Response(js.Response.redirect(url, int(status)))
93
+
94
+ @staticmethod
95
+ def from_json(
96
+ data: Any,
97
+ status: HTTPStatus | int = HTTPStatus.OK,
98
+ status_text: str = "",
99
+ headers: "Headers | None" = None,
100
+ ) -> "Response":
101
+ options = Response._create_options(status, status_text, headers)
102
+
103
+ with python_errors():
104
+ if isinstance(data, JsProxy):
105
+ return Response(js.Response.json(data, **options))
106
+
107
+ if "headers" not in options:
108
+ options["headers"] = to_js_headers({"content-type": "application/json"})
109
+ elif not options["headers"].has("content-type"):
110
+ options["headers"].set("content-type", "application/json")
111
+
112
+ return Response(js.Response.new(json.dumps(data), **options))
113
+
114
+ # A descriptor where Pyodide has a method, so ty reports the override.
115
+ json = _Json() # ty: ignore[invalid-attribute-override]
116
+
117
+
118
+ class Response(FetchResponse):
119
+ """An HTTP response, with an API like the web's Response."""
120
+
121
+ def __init__(
122
+ self,
123
+ body: "Body" = None,
124
+ status: HTTPStatus | int | None = None,
125
+ status_text: str = "",
126
+ headers: "Headers | None" = None,
127
+ web_socket: "js.WebSocket | None" = None,
128
+ ) -> None:
129
+ name = js_name(body)
130
+ if name is not None:
131
+ if name not in RESPONSE_ACCEPTED_TYPES:
132
+ raise TypeError(f"Unsupported type in Response: {name}")
133
+ elif not isinstance(body, (str, bytes, FormData, Blob)) and body is not None:
134
+ # The stock SDK also rejects a Blob.
135
+ raise TypeError(f"Unsupported type in Response: {type(body).__name__}")
136
+
137
+ if name == "Response":
138
+ if status is not None or status_text or headers is not None:
139
+ raise ValueError(
140
+ "Expected no options when constructing Response from a js.Response"
141
+ )
142
+ js_response: Any = body
143
+ super().__init__(js_response.url, js_response)
144
+ return
145
+
146
+ options = self._create_options(status, status_text, headers, web_socket)
147
+ with js_body(body) as converted:
148
+ js_response = js.Response.new(converted, **options)
149
+ super().__init__(js_response.url, js_response)
150
+
151
+ def __repr__(self) -> str:
152
+ parts = [f"status={self.status}"]
153
+ if self.js_object.statusText:
154
+ parts.append(f"status_text={self.status_text!r}")
155
+ if "content-type" in self.headers:
156
+ parts.append(f"content_type={self.headers['content-type']!r}")
157
+ if self.js_object.url:
158
+ parts.append(f"url={self.js_object.url!r}")
159
+ if self.js_object.type != "default":
160
+ parts.append(f"type={self.js_object.type!r}")
161
+
162
+ return f"Response({', '.join(parts)})"
163
+
164
+ @staticmethod
165
+ def _create_options(
166
+ status: HTTPStatus | int | None = HTTPStatus.OK,
167
+ status_text: str = "",
168
+ headers: "Headers | None" = None,
169
+ web_socket: "js.WebSocket | None" = None,
170
+ ) -> dict[str, Any]:
171
+ options: dict[str, Any] = {}
172
+ if status:
173
+ options["status"] = int(status)
174
+ if status_text:
175
+ options["statusText"] = status_text
176
+ if headers:
177
+ options["headers"] = to_js_headers(headers)
178
+ if web_socket:
179
+ options["webSocket"] = web_socket
180
+
181
+ return options
workers/rpc.py ADDED
@@ -0,0 +1,154 @@
1
+ import datetime
2
+ import inspect
3
+ from typing import Any
4
+
5
+ import js
6
+ from pyodide.ffi import create_proxy, to_js
7
+
8
+ from ._js import js_name, jsnull
9
+ from .blob import Blob, File
10
+ from .formdata import FormData
11
+ from .request import Request
12
+ from .response import Response
13
+
14
+ __all__ = ["JsDict", "python_from_rpc", "python_to_rpc"]
15
+
16
+
17
+ def python_from_rpc(obj: Any) -> Any:
18
+ """Convert a value workerd received over RPC to Python, as the stock SDK does."""
19
+ if obj is jsnull:
20
+ return None
21
+
22
+ name = js_name(obj)
23
+ if name is None:
24
+ return obj
25
+ if name == "TestController":
26
+ return None
27
+
28
+ return _null_to_none(obj.to_py(default_converter=_default_from_js))
29
+
30
+
31
+ def python_to_rpc(value: Any) -> Any:
32
+ """Convert a Python value to send over RPC, as the stock SDK does."""
33
+ value = _none_to_null(value)
34
+ if value is jsnull:
35
+ return value
36
+
37
+ # to_js converts some containers without asking the default converter.
38
+ _refuse_unsendable(value)
39
+ return to_js(
40
+ value, default_converter=_default_to_js, dict_converter=js.Object.fromEntries
41
+ )
42
+
43
+
44
+ class JsDict(dict):
45
+ """A dict received over RPC, whose keys also read as attributes."""
46
+
47
+ def __getattr__(self, name: str) -> Any:
48
+ try:
49
+ return self[name]
50
+ except KeyError:
51
+ raise AttributeError(name) from None
52
+
53
+ def __setattr__(self, name: str, value: Any) -> None:
54
+ self[name] = value
55
+
56
+
57
+ _PYTHON_TWINS = {
58
+ "Response": Response,
59
+ "Request": Request,
60
+ "FormData": FormData,
61
+ "Blob": Blob,
62
+ "File": File,
63
+ "Date": lambda date: datetime.datetime.fromtimestamp(date.getTime() / 1000),
64
+ "Error": lambda error: Exception(error.toString()),
65
+ "Number": lambda number: number.valueOf(),
66
+ }
67
+
68
+
69
+ def _default_from_js(value: Any, converter: Any, cache: Any) -> Any:
70
+ if value is jsnull:
71
+ return None
72
+ twin = _PYTHON_TWINS.get(js_name(value) or "")
73
+ return twin(value) if twin else value
74
+
75
+
76
+ def _null_to_none(value: Any) -> Any:
77
+ if value is jsnull:
78
+ return None
79
+ if isinstance(value, dict):
80
+ return JsDict({k: _null_to_none(v) for k, v in value.items()})
81
+ if isinstance(value, list):
82
+ return [_null_to_none(v) for v in value]
83
+ return value
84
+
85
+
86
+ def _none_to_null(value: Any) -> Any:
87
+ if value is None:
88
+ return jsnull
89
+ if isinstance(value, dict):
90
+ return {k: _none_to_null(v) for k, v in value.items()}
91
+ if isinstance(value, list):
92
+ return [_none_to_null(v) for v in value]
93
+ return value
94
+
95
+
96
+ def _refuse_unsendable(value: Any) -> None:
97
+ # A wrapper is sent as its JS object, and callables as proxies.
98
+ if hasattr(value, "js_object") or (callable(value) and not isinstance(value, type)):
99
+ return
100
+
101
+ if js_name(value) == "RegExp":
102
+ raise TypeError(f"{value.constructor.name} cannot be sent over RPC.")
103
+ if isinstance(value, (tuple, bytearray)):
104
+ raise TypeError(f"{type(value)} cannot be sent over RPC.")
105
+ if inspect.isawaitable(value):
106
+ raise TypeError(f"Awaitable {type(value)} cannot be sent over RPC.")
107
+ if isinstance(value, (str, bytes)):
108
+ return
109
+
110
+ try:
111
+ items = iter(value.values() if isinstance(value, dict) else value)
112
+ except TypeError:
113
+ return
114
+
115
+ # Iterating an iterator such as a generator would empty it before it is
116
+ # sent, which the stock SDK does. A container gives a fresh iterator.
117
+ if items is value:
118
+ return
119
+ for item in items:
120
+ _refuse_unsendable(item)
121
+
122
+
123
+ def _default_to_js(value: Any, converter: Any, cache: Any) -> Any:
124
+ if hasattr(value, "js_object"):
125
+ return value.js_object
126
+ if isinstance(value, datetime.datetime):
127
+ return js.Date.new(value.timestamp() * 1000)
128
+ if isinstance(value, Exception):
129
+ return js.Error.new(str(value))
130
+ if callable(value) and not isinstance(value, type):
131
+ # A persistent proxy, so the receiver can call it after this returns.
132
+ return create_proxy(value)
133
+
134
+ _refuse_unsendable(value)
135
+ return value
136
+
137
+
138
+ # The stock SDK's private wrapper names, which its own tests import from here,
139
+ # and the crosswind classes that play their parts.
140
+ _STOCK_PRIVATE_NAMES = {
141
+ "_BindingWrapper": "Binding",
142
+ "_EnvWrapper": "_EnvWrapper",
143
+ "_FetcherWrapper": "Fetcher",
144
+ "_RpcStubWrapper": "_RpcStub",
145
+ }
146
+
147
+
148
+ def __getattr__(name: str) -> Any:
149
+ # Served lazily, because bindings imports this module.
150
+ if name in _STOCK_PRIVATE_NAMES:
151
+ from . import bindings
152
+
153
+ return getattr(bindings, _STOCK_PRIVATE_NAMES[name])
154
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
workers/types.py ADDED
@@ -0,0 +1,57 @@
1
+ from collections.abc import Awaitable
2
+ from http import HTTPMethod
3
+ from typing import TYPE_CHECKING, Any, Protocol, TypedDict
4
+
5
+ if TYPE_CHECKING:
6
+ import js
7
+ from pyodide.ffi import JsBuffer
8
+
9
+ from .blob import Blob
10
+ from .formdata import FormData
11
+
12
+ __all__ = [
13
+ "Body",
14
+ "Context",
15
+ "FetchKwargs",
16
+ "Headers",
17
+ "JSBody",
18
+ "RequestInitCfProperties",
19
+ ]
20
+
21
+
22
+ class Context(Protocol):
23
+ def waitUntil(self, other: Awaitable[Any]) -> None: ... # noqa: N802
24
+
25
+
26
+ type JSBody = js.Blob | JsBuffer | js.FormData | js.ReadableStream | js.URLSearchParams
27
+ type Body = str | bytes | FormData | Blob | JSBody | None
28
+ type Headers = dict[str, str] | list[tuple[str, str]] | js.Headers
29
+
30
+
31
+ class RequestInitCfProperties(TypedDict, total=False):
32
+ """https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties"""
33
+
34
+ apps: bool | None
35
+ cacheEverything: bool | None
36
+ cacheKey: str | None
37
+ cacheTags: list[str] | None
38
+ cacheTtl: int
39
+ cacheTtlByStatus: dict[str, int]
40
+ image: Any | None
41
+ mirage: bool | None
42
+ polish: str | None
43
+ resolveOverride: str | None
44
+ scrapeShield: bool | None
45
+ webp: bool | None
46
+
47
+
48
+ class FetchKwargs(TypedDict, total=False):
49
+ """https://developers.cloudflare.com/workers/runtime-apis/request/#options"""
50
+
51
+ headers: "Headers | None"
52
+ body: "Body | None"
53
+ method: HTTPMethod | str | None
54
+ redirect: str | None
55
+ cf: RequestInitCfProperties | None
56
+ # A JS fetch function, such as a service binding's.
57
+ fetcher: Any
workers/utils.py ADDED
@@ -0,0 +1,55 @@
1
+ """Module-level helpers with the stock SDK's names and import paths."""
2
+
3
+ from typing import Any, NoReturn
4
+
5
+ import _pyodide_entrypoint_helper
6
+ from pyodide.ffi import JsException
7
+
8
+ __all__ = ["import_from_javascript", "patch_env"]
9
+
10
+ # JS modules workerd imports ahead of time, by the helper attribute holding each.
11
+ _PRELOADED = {
12
+ "cloudflare:workers": "cloudflareWorkersModule",
13
+ "cloudflare:sockets": "cloudflareSocketsModule",
14
+ }
15
+
16
+
17
+ def import_from_javascript(module_name: str) -> Any:
18
+ """Import a JS module, such as "cloudflare:sockets", from Python.
19
+
20
+ "cloudflare:workers" and "cloudflare:sockets" are imported ahead of time,
21
+ so they work everywhere. Any other module goes through run_sync, which
22
+ Python 3.12 Workers lack. On 3.13 and 3.14 it works at top level too.
23
+ """
24
+ if module_name in _PRELOADED:
25
+ return getattr(_pyodide_entrypoint_helper, _PRELOADED[module_name])
26
+
27
+ failed = f"Failed to import '{module_name}'"
28
+ try:
29
+ from pyodide.ffi import run_sync
30
+ except ImportError as error:
31
+ raise ImportError(
32
+ f"{failed}: Only 'cloudflare:workers' and 'cloudflare:sockets' are "
33
+ "available until the next python runtime version."
34
+ ) from error
35
+
36
+ try:
37
+ return run_sync(_pyodide_entrypoint_helper.doAnImport(module_name))
38
+ except JsException as error:
39
+ raise ImportError(f"{failed}: {error}") from error
40
+ except RuntimeError as error:
41
+ if error.args and error.args[0] == "No suspender":
42
+ raise ImportError(
43
+ f"{failed}: Only 'cloudflare:workers' and 'cloudflare:sockets' "
44
+ "are available in the global scope."
45
+ ) from error
46
+ raise
47
+
48
+
49
+ def patch_env(*args: Any, **kwargs: Any) -> NoReturn:
50
+ """Removed from workerd. The stock SDK still exports it, so crosswind does."""
51
+ raise NotImplementedError(
52
+ "patch_env needs a workerd helper that workerd removed on 2026-06-30 "
53
+ "(commit 6581cf0), so it can't swap env. Pass the values you need "
54
+ "explicitly instead."
55
+ )