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.
@@ -0,0 +1,13 @@
1
+ """Package patches that workerd leaves to the SDK.
2
+
3
+ With `python_process_pth_files` on, workerd stops bundling its per-package
4
+ entropy patches and runs `.pth` files instead, and `crosswind_workers_patches.pth`
5
+ imports this package at startup. The patches are ported from the stock SDK at
6
+ cb2db3c. They hook imports through workerd's `_cloudflare` package, so a patch
7
+ applies only when its package is imported, and nothing happens outside workerd.
8
+ """
9
+
10
+ import importlib.util
11
+
12
+ if importlib.util.find_spec("_cloudflare") is not None:
13
+ from . import entropy, runtime # noqa: F401
@@ -0,0 +1,259 @@
1
+ """
2
+ Top level entropy patches for packages
3
+ """
4
+
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from contextlib import contextmanager
8
+ from importlib.abc import MetaPathFinder
9
+ from importlib.machinery import ModuleSpec
10
+ from importlib.metadata import PackageNotFoundError, version
11
+ from types import ModuleType
12
+
13
+ from _cloudflare.allow_entropy import (
14
+ allow_bad_entropy_calls,
15
+ )
16
+ from _cloudflare.import_patch_manager import (
17
+ block_calls,
18
+ register_after_snapshot,
19
+ register_before_first_request,
20
+ register_create_patch,
21
+ register_exec_patch,
22
+ )
23
+
24
+
25
+ @contextmanager
26
+ def allow_bad_entropy_calls_for_version(
27
+ package_name: str,
28
+ num_calls: int,
29
+ min_version: tuple[int, ...],
30
+ max_version: tuple[int, ...] | None = None,
31
+ ):
32
+ try:
33
+ package_version = tuple(map(int, version(package_name).split(".")))
34
+ except PackageNotFoundError:
35
+ yield
36
+ return
37
+
38
+ if package_version < min_version or (
39
+ max_version is not None and package_version > max_version
40
+ ):
41
+ yield
42
+ return
43
+
44
+ with allow_bad_entropy_calls(num_calls):
45
+ yield
46
+
47
+
48
+ class STATE:
49
+ imported_rust_package = False
50
+ numpy_random = None
51
+
52
+
53
+ @register_create_patch("tiktoken._tiktoken")
54
+ @register_exec_patch("cryptography.exceptions")
55
+ @register_exec_patch("jiter")
56
+ @register_exec_patch("rpds")
57
+ @register_exec_patch("uuid_utils._uuid_utils")
58
+ @contextmanager
59
+ def rust_package_context(module):
60
+ """Rust packages need one entropy call if they create a rust hash map at
61
+ init time.
62
+
63
+ For reasons I don't entirely understand, in Pyodide 0.28 only the first Rust
64
+ package to be imported makes the get_entropy call. See gen_rust_import_tests()
65
+ which tests that importing four rust packages in different permutations works
66
+ correctly.
67
+ """
68
+ if STATE.imported_rust_package:
69
+ yield
70
+ return
71
+
72
+ STATE.imported_rust_package = True
73
+ with allow_bad_entropy_calls(1):
74
+ yield
75
+
76
+
77
+ @register_exec_patch("numpy.random")
78
+ @contextmanager
79
+ def numpy_random_context(numpy_random):
80
+ """numpy.random doesn't call getentropy() itself, but we want to block calls
81
+ that might use the bad seed.
82
+
83
+ TODO: Maybe there are more calls we can whitelist?
84
+ TODO: Is it not enough to just block numpy.random.mtrand calls?
85
+ """
86
+ yield
87
+ # Calling default_rng() with a given seed is fine, calling it without a seed
88
+ # will call getentropy() and fail.
89
+ block_calls(numpy_random, allowlist=("default_rng", "RandomState"))
90
+
91
+
92
+ @register_after_snapshot("numpy.random")
93
+ def numpy_random_after_snapshot(numpy_random):
94
+ r1 = numpy_random.random()
95
+ numpy_random.set_state(STATE.numpy_random)
96
+ r2 = numpy_random.random()
97
+ if r1 != r2:
98
+ raise RuntimeError("random seed in bad state")
99
+
100
+
101
+ @register_before_first_request("numpy.random")
102
+ def numpy_random_before_first_request(numpy_random):
103
+ numpy_random.seed()
104
+
105
+
106
+ @register_exec_patch("numpy.random.mtrand")
107
+ @contextmanager
108
+ def numpy_random_mtrand_context(module):
109
+ # numpy.random.mtrand calls secrets.randbits at top level to seed itself.
110
+ # This will fail if we don't let it through.
111
+ with allow_bad_entropy_calls(1):
112
+ yield
113
+ # Block calls until we get a chance to replace the bad random seed.
114
+ STATE.numpy_random = module.get_state()
115
+ block_calls(module, allowlist=("RandomState",))
116
+
117
+
118
+ @register_exec_patch("pydantic_core")
119
+ @contextmanager
120
+ def pydantic_core_context(module):
121
+ try:
122
+ # Initial import needs one entropy call to initialize
123
+ # std::collections::HashMap hash seed
124
+ with allow_bad_entropy_calls(1):
125
+ yield
126
+ finally:
127
+ # Build a tiny validator instead so any lazy hash-map seeds are initialized
128
+ # before the snapshot is captured.
129
+ with allow_bad_entropy_calls(1):
130
+ module.SchemaValidator({"type": "any"})
131
+
132
+
133
+ @register_exec_patch("aiohttp.http_websocket")
134
+ @contextmanager
135
+ def aiohttp_http_websocket_context(module):
136
+ import random
137
+
138
+ original = random.Random
139
+ # The module stands in for the Random() aiohttp builds at import time.
140
+ # setattr, because the stand-in is not a type, so ty rejects an assignment.
141
+ setattr(random, "Random", lambda: random) # noqa: B010
142
+ try:
143
+ yield
144
+ finally:
145
+ random.Random = original
146
+
147
+
148
+ class NoSslFinder(MetaPathFinder):
149
+ def find_spec(
150
+ self,
151
+ fullname: str,
152
+ path: Sequence[str] | None,
153
+ target: ModuleType | None = None,
154
+ ) -> ModuleSpec | None:
155
+ if fullname == "ssl":
156
+ raise ModuleNotFoundError(
157
+ f"No module named {fullname!r}", name=fullname
158
+ ) from None
159
+ return None
160
+
161
+
162
+ @contextmanager
163
+ def no_ssl():
164
+ """
165
+ Various packages will call ssl.create_default_context() at top level which uses
166
+ entropy if they can import ssl. By temporarily making importing ssl raise an
167
+ import error, we exercise the workaround code and so avoid the entropy calls.
168
+ After, we put the ssl module back to the normal value.
169
+ """
170
+ try:
171
+ f = NoSslFinder()
172
+ ssl = sys.modules.pop("ssl", None)
173
+ sys.meta_path.insert(0, f)
174
+ yield
175
+ finally:
176
+ sys.meta_path.remove(f)
177
+ if ssl:
178
+ sys.modules["ssl"] = ssl
179
+
180
+
181
+ @register_exec_patch("aiohttp.connector")
182
+ @contextmanager
183
+ def aiohttp_connector_context(module):
184
+ with no_ssl():
185
+ yield
186
+
187
+
188
+ @register_exec_patch("requests.adapters")
189
+ @contextmanager
190
+ def requests_adapters_context(module):
191
+ with no_ssl():
192
+ yield
193
+
194
+
195
+ @register_exec_patch("urllib3.util.ssl_")
196
+ @contextmanager
197
+ def urllib3_util_ssl__context(module):
198
+ with no_ssl():
199
+ yield
200
+
201
+
202
+ @register_exec_patch("langsmith._internal._constants")
203
+ @contextmanager
204
+ def langsmith__internal__constants_context(module):
205
+ # Langsmith uses a UUID to communicate with a background thread. This obviously
206
+ # won't work so we might as well allow it to make a UUID.
207
+ with allow_bad_entropy_calls(1):
208
+ yield
209
+
210
+
211
+ @register_exec_patch("langchain_openai.chat_models.base")
212
+ @contextmanager
213
+ def langchain_openai_chat_models_base_context(module):
214
+ with allow_bad_entropy_calls(1):
215
+ yield
216
+
217
+
218
+ @register_exec_patch("opentelemetry.context")
219
+ @contextmanager
220
+ def opentelemetry_context(module):
221
+ # OpenTelemetry creates three UUID-backed keys.
222
+ with allow_bad_entropy_calls_for_version("opentelemetry-api", 3, (1, 40, 0)):
223
+ yield
224
+
225
+
226
+ @register_exec_patch("opentelemetry.trace.propagation")
227
+ @contextmanager
228
+ def opentelemetry_trace_propagation_context(module):
229
+ # OpenTelemetry creates a UUID-backed key.
230
+ with allow_bad_entropy_calls(1):
231
+ yield
232
+
233
+
234
+ @register_exec_patch("opentelemetry.baggage")
235
+ @contextmanager
236
+ def opentelemetry_baggage_context(module):
237
+ # OpenTelemetry creates a UUID-backed key.
238
+ with allow_bad_entropy_calls(1):
239
+ yield
240
+
241
+
242
+ @register_exec_patch("fastapi.telemetry")
243
+ @contextmanager
244
+ def fastapi_telemetry_context(module):
245
+ # fastapi 0.142.0 added opentelemetry support
246
+ # which creates a UUID-backed key.
247
+ with allow_bad_entropy_calls_for_version("fastapi", 1, (0, 142, 0)):
248
+ yield
249
+
250
+
251
+ @register_exec_patch("litestar.openapi.controller")
252
+ @register_exec_patch("litestar.constants")
253
+ @contextmanager
254
+ def litestar_context(module):
255
+ # Uses os.urandom() to generate a OPENAPI_JSON_HANDLER_NAME
256
+ # https://github.com/litestar-org/litestar/blob/e3b6a1d103a9160a575dce01ae34839e6e9bf990/litestar/constants.py#L19
257
+ # https://github.com/litestar-org/litestar/blob/v2.24.0/litestar/openapi/controller.py#L27
258
+ with allow_bad_entropy_calls(1):
259
+ yield
@@ -0,0 +1,45 @@
1
+ """
2
+ Top level runtime compatibility patches for packages.
3
+
4
+ These patches adapt packages that assume capabilities the workers runtime does
5
+ not provide. Unlike the entropy patches, they are not related to snapshotting.
6
+ """
7
+
8
+ from contextlib import contextmanager
9
+
10
+ from _cloudflare.import_patch_manager import (
11
+ register_exec_patch,
12
+ )
13
+
14
+
15
+ @register_exec_patch("anyio.to_thread")
16
+ @contextmanager
17
+ def anyio_to_thread_context(module):
18
+ """`anyio.to_thread.run_sync` dispatches blocking work to an OS thread, but the
19
+ workers runtime is single threaded, so starting a thread raises
20
+ `RuntimeError: can't start new thread`. Run the callable inline instead.
21
+
22
+ This function is the only caller of `run_sync_in_worker_thread`, which makes it
23
+ the single chokepoint for every blocking call anyio makes on behalf of a
24
+ framework. In starlette/fastapi that covers sync route handlers, sync
25
+ dependencies, `StaticFiles`, `FileResponse`, `UploadFile`, `BackgroundTask` and
26
+ `GZipMiddleware`, as well as `anyio.Path` and `anyio.open_file`.
27
+
28
+ Patching the module attribute is enough because every caller looks it up at
29
+ call time (e.g. `starlette.concurrency` and `anyio._core._fileio` both import
30
+ the module rather than the function).
31
+
32
+ We patch `anyio.to_thread` rather than the backend's
33
+ `run_sync_in_worker_thread` because `anyio._backends._asyncio` is imported
34
+ lazily by `get_async_backend()` during a request, by which point the import
35
+ patch manager is no longer installed.
36
+ """
37
+ yield
38
+
39
+ async def run_sync(func, *args, **kwargs):
40
+ # Callers pass the function's own arguments positionally and anyio's
41
+ # options (`limiter`, `abandon_on_cancel`, `cancellable`) as keywords.
42
+ # None of the options mean anything without threads, so drop them.
43
+ return func(*args)
44
+
45
+ module.run_sync = run_sync
asgi/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`import asgi`, the stock SDK's older import path for `workers.asgi`."""
2
+
3
+ import sys
4
+
5
+ from workers import asgi as _asgi
6
+
7
+ sys.modules[__name__] = _asgi
@@ -0,0 +1 @@
1
+ import _crosswind_patches
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.3
2
+ Name: crosswind-workers
3
+ Version: 0.1.0
4
+ Summary: Typed, Pythonic drop-in replacement for Cloudflare's workers-runtime-sdk
5
+ Author: Viswa Marepalli
6
+ Author-email: Viswa Marepalli <reachvishm@gmail.com>
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+
@@ -0,0 +1,35 @@
1
+ _crosswind_patches/__init__.py,sha256=X9yG9DBf8RBGeufuHHsY8s8hWm07DFUBtG2UEnvTuq4,580
2
+ _crosswind_patches/entropy.py,sha256=83ocwaBDtuA7__cJRZzBuY7bxiuU-_kluNtdUgnzjpI,7551
3
+ _crosswind_patches/runtime.py,sha256=Xbsukr-l2_SBWRJ1aErQ7eoGo4sqDFFDfHWSbWE2WX4,1868
4
+ asgi/__init__.py,sha256=lAOCoCmnC5gXiW5PcmGkLHCq5W0U55snJYSEwNBJ2YQ,153
5
+ workers/__init__.py,sha256=9J5xggHH_0vwCUjU_SufXK5Cypo-SoLZd8Rvw4Nijzk,2778
6
+ workers/_adapter.py,sha256=SToaS9ONcNdJTlprR1Di8YWjjpTPYjrySySr5LiXL48,950
7
+ workers/_body.py,sha256=gwRJttYqBnSeeLX71DJCRWRwfSnagAEUUxrEPMdYqGs,898
8
+ workers/_js.py,sha256=ZSdaV0dwaW0qWUZgNGAF-oA45XCY933PD04X6TgS3cA,3611
9
+ workers/asgi.py,sha256=Y02rznPW1ZlOeo5hYrCwkKud2dU15gSGzNFG0effR2U,18852
10
+ workers/bindings/__init__.py,sha256=XvJx5NlBJaSt_8VgA3iz-_jntyywpExXaYlzTODTv5I,1170
11
+ workers/bindings/base.py,sha256=DXRU4G9zZUA83sxPtxihRboCRj4-D3sO3BYIq2RTw6E,4750
12
+ workers/bindings/d1.py,sha256=_p_la1JJn6NRNXejhsxj143p_eeKSU5vioRH8Y0h6LA,1599
13
+ workers/bindings/env.py,sha256=h23XAKU05eiJaRgw2O0xZUBI4ZSmzU0QrLUc77IWMyE,1515
14
+ workers/bindings/kv.py,sha256=p66wqgyaQ1nxElhcZowV4Wlv9zylr87gfXSoZ3XYITk,3276
15
+ workers/bindings/queues.py,sha256=GuvlG0xKnm-9UePYR_w5ZNBX0rQb5B0Q0buM-207Lq8,970
16
+ workers/bindings/r2.py,sha256=P8seoIxkZmRMLZkvYW7n4uwyApc8Y9VbakYVpZRI-0Y,3482
17
+ workers/bindings/services.py,sha256=lxxfgySmHC0v_lD7j25sFexHJ8jDhRSbBwe6HBUoq3k,2035
18
+ workers/bindings/workflow.py,sha256=RKcNJHKtE3X6OGGvCXo6qYVUjxahkW5qJdQxdUJ_dpo,1864
19
+ workers/blob.py,sha256=YVgUKdqoW0Iy4Jeatspiu3bDc65KoyS1HCo0lnH1R9o,4539
20
+ workers/context.py,sha256=yPGuiKYmDO0_sF_JOXzpgXWD85wU2VaHFWuR_zlslF4,7965
21
+ workers/entrypoints.py,sha256=SNUJcGP7mfOt3a79rg5Zd-GPbi7OiqhJ8Ed6yqlifTs,6188
22
+ workers/fetch.py,sha256=O3iyn_2GGQJFJoTnef8mlVvbRFMd8P1UIjllwkWzaP8,1608
23
+ workers/formdata.py,sha256=LwuZAAHNMU-GguaKQLtHsv1Iz274oId5O2-OZm6IVeg,3215
24
+ workers/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ workers/request.py,sha256=7Md0vLWLi2U5BMQ9I2l3ooi8UUsb7CZ9a90yrCI5twA,3878
26
+ workers/response.py,sha256=Xtq5SUuFO9U1X4aXCNyVBxS7OHeQWHX9FUc51QGuPMY,6164
27
+ workers/rpc.py,sha256=LuVv2fnxqzxAl62g1YDlTI03UyZFHg0xG4oOvsT-7EY,4623
28
+ workers/types.py,sha256=8fxCdEPqTSZ32AS2EDnSh4nzPp81lgv2t5n4difJw-A,1542
29
+ workers/utils.py,sha256=6EqXQYeY3x6eKAYAac4E8pssE2vJ2VOxhkc1e-UxEus,2066
30
+ workers/workflows.py,sha256=nbQmgPX6leuJZCKuacLLU3nxa3DaWON2Ms3SNSKAPu8,9631
31
+ workers/wsgi.py,sha256=7ir1_vQbz1comSYM8QmY1gfD8NE7CX4epD_vKpkJybs,14390
32
+ crosswind_workers-0.1.0.data/purelib/crosswind_workers_patches.pth,sha256=Z0CLXaLtPU3a08bW3w2y3ti-vr1l2oYbfaulcGx1sfk,26
33
+ crosswind_workers-0.1.0.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
34
+ crosswind_workers-0.1.0.dist-info/METADATA,sha256=yugjwOJzgQ5E8lrgOSoqBgdZxrdf2M0D7jxrGB6Neag,285
35
+ crosswind_workers-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
workers/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
2
+ from importlib.metadata import distribution as _distribution
3
+ from typing import Any as _Any
4
+
5
+ # Both SDKs install the `workers` package, so with both installed one
6
+ # overwrote the other's files. This check survives only if crosswind's did.
7
+ try:
8
+ _distribution("workers-runtime-sdk")
9
+ except _PackageNotFoundError:
10
+ pass
11
+ else:
12
+ raise ImportError(
13
+ "workers-runtime-sdk is installed beside crosswind-workers, and both "
14
+ "provide the `workers` package, so one has overwritten the other. "
15
+ "Remove workers-runtime-sdk. pywrangler always adds it, so build "
16
+ "crosswind-workers projects with Wynd, which installs only the SDK the "
17
+ "project declares."
18
+ )
19
+
20
+ from .bindings import _EnvWrapper
21
+ from .blob import Blob, BlobEnding, BlobValue, File
22
+ from .context import js_promise as _js_promise
23
+ from .entrypoints import DurableObject, WorkerEntrypoint, WorkflowEntrypoint, handler
24
+ from .fetch import fetch
25
+ from .formdata import FormData, FormDataValue
26
+ from .request import Request
27
+ from .response import FetchResponse, Response
28
+ from .rpc import python_from_rpc, python_to_rpc
29
+ from .types import (
30
+ Body,
31
+ Context,
32
+ FetchKwargs,
33
+ Headers,
34
+ JSBody,
35
+ RequestInitCfProperties,
36
+ )
37
+ from .utils import import_from_javascript, patch_env
38
+
39
+ __all__ = [
40
+ "Blob",
41
+ "BlobEnding",
42
+ "BlobValue",
43
+ "Body",
44
+ "Context",
45
+ "DurableObject",
46
+ "FetchKwargs",
47
+ "FetchResponse",
48
+ "File",
49
+ "FormData",
50
+ "FormDataValue",
51
+ "Headers",
52
+ "JSBody",
53
+ "Request",
54
+ "RequestInitCfProperties",
55
+ "Response",
56
+ "WorkerEntrypoint",
57
+ "WorkflowEntrypoint",
58
+ "env",
59
+ "fetch",
60
+ "handler",
61
+ "import_from_javascript",
62
+ "patch_env",
63
+ "python_from_rpc",
64
+ "python_to_rpc",
65
+ "waitUntil",
66
+ "wait_until",
67
+ ]
68
+
69
+
70
+ def _wait_until(awaitable: _Any) -> None:
71
+ """Hand `awaitable` to cloudflare:workers' waitUntil, as the stock SDK does.
72
+
73
+ Under wrangler dev that waitUntil doesn't keep a Python Worker alive past
74
+ the response, with this or with the stock SDK. `ctx.wait_until` does.
75
+ """
76
+ import_from_javascript("cloudflare:workers").waitUntil(_js_promise(awaitable))
77
+
78
+
79
+ def __getattr__(name: str) -> _Any:
80
+ # Served on access, as in the stock SDK: importing workers holds no JS
81
+ # object, which snapshot creation requires.
82
+ if name == "env":
83
+ return _EnvWrapper(import_from_javascript("cloudflare:workers").env)
84
+ if name in ("wait_until", "waitUntil"):
85
+ # workerd's own waitUntil drops a coroutine handed to it, so this
86
+ # passes a promise that follows it, as ctx.wait_until does.
87
+ return _wait_until
88
+ raise AttributeError(f"module 'workers' has no attribute {name!r}")
workers/_adapter.py ADDED
@@ -0,0 +1,24 @@
1
+ """Request and response handling shared by the ASGI and WSGI adapters."""
2
+
3
+ from collections.abc import Iterable
4
+ from typing import Any
5
+
6
+ from .request import Request
7
+
8
+ # 101/103/204/205/304 must not carry a body per the Fetch spec.
9
+ # https://fetch.spec.whatwg.org/#null-body-status
10
+ NULL_BODY_STATUSES = frozenset({101, 103, 204, 205, 304})
11
+
12
+
13
+ def request_headers(req: Any) -> Iterable[tuple[str, str]]:
14
+ """The header pairs of a `workers.Request` or a `js.Request`."""
15
+ return req.headers.items() if isinstance(req, Request) else req.headers
16
+
17
+
18
+ def body_encoding(headers: Iterable[tuple[str, str]]) -> dict[str, str]:
19
+ """The `Response` init entry for a body the app has already encoded."""
20
+ if any(name.lower() == "content-encoding" for name, _ in headers):
21
+ # The app encoded the body already, as GZipMiddleware does. Without
22
+ # this the runtime encodes it a second time.
23
+ return {"encodeBody": "manual"}
24
+ return {}
workers/_body.py ADDED
@@ -0,0 +1,34 @@
1
+ """What Request and Response share on top of their JS object."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from ._js import headers_to_message, python_errors
6
+ from .blob import Blob
7
+ from .formdata import FormData
8
+
9
+
10
+ class BodyMixin:
11
+ """Headers and the body readers pyodide's FetchResponse lacks.
12
+
13
+ Built on `js_object`, a JS Request or Response, and `_raise_if_failed`.
14
+ """
15
+
16
+ if TYPE_CHECKING:
17
+
18
+ @property
19
+ def js_object(self) -> Any: ...
20
+
21
+ def _raise_if_failed(self) -> None: ...
22
+
23
+ @property
24
+ def headers(self) -> Any:
25
+ return headers_to_message(self.js_object.headers)
26
+
27
+ async def form_data(self) -> FormData:
28
+ self._raise_if_failed()
29
+ with python_errors():
30
+ return FormData(await self.js_object.formData())
31
+
32
+ async def blob(self) -> Blob:
33
+ self._raise_if_failed()
34
+ return Blob(await self.js_object.blob())
workers/_js.py ADDED
@@ -0,0 +1,121 @@
1
+ """Helpers for crossing between Python and JS values."""
2
+
3
+ import http.client
4
+ from collections.abc import Generator, Mapping
5
+ from contextlib import contextmanager
6
+ from typing import Any
7
+
8
+ import js
9
+ from pyodide.ffi import JsException, create_proxy, destroy_proxies
10
+
11
+ try:
12
+ # Pyodide 0.28 added jsnull. The pyodide-py stubs for 3.12 (0.27.8) lack it.
13
+ from pyodide.ffi import jsnull # ty: ignore[unresolved-import]
14
+ except ImportError:
15
+ # Pyodide 0.26 (the 3.12 target) has no jsnull, so None crosses as undefined.
16
+ jsnull = None
17
+
18
+ try:
19
+ import _cloudflare_compat_flags
20
+ except ImportError: # outside workerd
21
+ _cloudflare_compat_flags = None
22
+
23
+
24
+ def js_name(value: Any) -> str | None:
25
+ """The JS constructor name of a JS value, or None for a Python value."""
26
+ return value.constructor.name if hasattr(value, "constructor") else None
27
+
28
+
29
+ def none_if_null(value: Any) -> Any:
30
+ return None if value is jsnull else value
31
+
32
+
33
+ def compat_flag(name: str) -> bool:
34
+ return bool(getattr(_cloudflare_compat_flags, name, False))
35
+
36
+
37
+ @contextmanager
38
+ def js_body(body: Any) -> Generator[Any]:
39
+ """The JS value for a request or response body, valid inside the block."""
40
+ if isinstance(body, bytes):
41
+ with lend_buffer(body) as view:
42
+ yield view
43
+ elif hasattr(body, "js_object"):
44
+ yield body.js_object
45
+ else:
46
+ yield body
47
+
48
+
49
+ @contextmanager
50
+ def lend_buffer(value: Any) -> Generator[Any]:
51
+ """A JS view of a Python buffer, without a copy, valid inside the block."""
52
+ proxy = create_proxy(value)
53
+ buffer = proxy.getBuffer()
54
+
55
+ try:
56
+ yield buffer.data
57
+ finally:
58
+ buffer.release()
59
+ proxy.destroy()
60
+
61
+
62
+ @contextmanager
63
+ def managed_pyproxies() -> Generator[Any]:
64
+ proxies = js.Array.new()
65
+ try:
66
+ yield proxies
67
+ finally:
68
+ destroy_proxies(proxies)
69
+
70
+
71
+ # JS errors with a Python twin, by JS name.
72
+ _PYTHON_ERRORS = {"RangeError": ValueError, "TypeError": TypeError}
73
+
74
+
75
+ @contextmanager
76
+ def python_errors() -> Generator[None]:
77
+ """Raise a JS RangeError or TypeError from the block as its Python twin."""
78
+ try:
79
+ yield
80
+ except JsException as error:
81
+ python_error = _PYTHON_ERRORS.get(error.name)
82
+ if python_error is None:
83
+ raise
84
+ raise python_error(error.message) from error
85
+
86
+
87
+ def to_js_headers(headers: Any) -> Any:
88
+ if isinstance(headers, list):
89
+ return js.Headers.new(headers)
90
+ if isinstance(headers, dict):
91
+ return js.Headers.new(headers.items())
92
+ if js_name(headers) == "Headers":
93
+ return headers
94
+ raise TypeError("Received unexpected type for headers argument")
95
+
96
+
97
+ # The stock SDK's type: an HTTPMessage that is also a Mapping. The two bases
98
+ # type keys(), get() and the rest differently, which ty reports.
99
+ class HTTPMessageMapping(http.client.HTTPMessage, Mapping): # ty: ignore[invalid-method-override]
100
+ pass
101
+
102
+
103
+ def headers_to_message(js_headers: Any) -> HTTPMessageMapping:
104
+ # Newer Pyodide versions already expose headers as an HTTPMessage.
105
+ if isinstance(js_headers, HTTPMessageMapping):
106
+ return js_headers
107
+
108
+ result = HTTPMessageMapping()
109
+ if not compat_flag("python_request_headers_preserve_commas"):
110
+ for key, value in js_headers:
111
+ result[key] = value.strip()
112
+ return result
113
+
114
+ # JS joins duplicate headers with commas, except Set-Cookie.
115
+ for value in js_headers.getSetCookie() or ():
116
+ result.add_header("Set-Cookie", value.strip())
117
+ for key, value in js_headers:
118
+ if key.lower() != "set-cookie":
119
+ result.add_header(key, value.strip())
120
+
121
+ return result