crosswind-workers 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. crosswind_workers-0.1.0/PKG-INFO +9 -0
  2. crosswind_workers-0.1.0/README.md +0 -0
  3. crosswind_workers-0.1.0/purelib/crosswind_workers_patches.pth +1 -0
  4. crosswind_workers-0.1.0/pyproject.toml +63 -0
  5. crosswind_workers-0.1.0/pyproject.toml.orig +49 -0
  6. crosswind_workers-0.1.0/src/_crosswind_patches/__init__.py +13 -0
  7. crosswind_workers-0.1.0/src/_crosswind_patches/entropy.py +259 -0
  8. crosswind_workers-0.1.0/src/_crosswind_patches/runtime.py +45 -0
  9. crosswind_workers-0.1.0/src/asgi/__init__.py +7 -0
  10. crosswind_workers-0.1.0/src/workers/__init__.py +88 -0
  11. crosswind_workers-0.1.0/src/workers/_adapter.py +24 -0
  12. crosswind_workers-0.1.0/src/workers/_body.py +34 -0
  13. crosswind_workers-0.1.0/src/workers/_js.py +121 -0
  14. crosswind_workers-0.1.0/src/workers/asgi.py +523 -0
  15. crosswind_workers-0.1.0/src/workers/bindings/__init__.py +36 -0
  16. crosswind_workers-0.1.0/src/workers/bindings/base.py +135 -0
  17. crosswind_workers-0.1.0/src/workers/bindings/d1.py +47 -0
  18. crosswind_workers-0.1.0/src/workers/bindings/env.py +50 -0
  19. crosswind_workers-0.1.0/src/workers/bindings/kv.py +121 -0
  20. crosswind_workers-0.1.0/src/workers/bindings/queues.py +36 -0
  21. crosswind_workers-0.1.0/src/workers/bindings/r2.py +130 -0
  22. crosswind_workers-0.1.0/src/workers/bindings/services.py +55 -0
  23. crosswind_workers-0.1.0/src/workers/bindings/workflow.py +64 -0
  24. crosswind_workers-0.1.0/src/workers/blob.py +158 -0
  25. crosswind_workers-0.1.0/src/workers/context.py +230 -0
  26. crosswind_workers-0.1.0/src/workers/entrypoints.py +183 -0
  27. crosswind_workers-0.1.0/src/workers/fetch.py +51 -0
  28. crosswind_workers-0.1.0/src/workers/formdata.py +92 -0
  29. crosswind_workers-0.1.0/src/workers/py.typed +0 -0
  30. crosswind_workers-0.1.0/src/workers/request.py +138 -0
  31. crosswind_workers-0.1.0/src/workers/response.py +181 -0
  32. crosswind_workers-0.1.0/src/workers/rpc.py +154 -0
  33. crosswind_workers-0.1.0/src/workers/types.py +57 -0
  34. crosswind_workers-0.1.0/src/workers/utils.py +55 -0
  35. crosswind_workers-0.1.0/src/workers/workflows.py +265 -0
  36. crosswind_workers-0.1.0/src/workers/wsgi.py +426 -0
@@ -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
+
File without changes
@@ -0,0 +1 @@
1
+ import _crosswind_patches
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "crosswind-workers"
3
+ version = "0.1.0"
4
+ description = "Typed, Pythonic drop-in replacement for Cloudflare's workers-runtime-sdk"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = []
8
+
9
+ [[project.authors]]
10
+ name = "Viswa Marepalli"
11
+ email = "reachvishm@gmail.com"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.12.19,<0.13.0"]
15
+ build-backend = "uv_build"
16
+
17
+ [tool.uv.build-backend]
18
+ module-name = [
19
+ "workers",
20
+ "_crosswind_patches",
21
+ "asgi",
22
+ ]
23
+
24
+ [tool.uv.build-backend.data]
25
+ purelib = "purelib"
26
+
27
+ [tool.ruff.lint]
28
+ extend-select = [
29
+ "E",
30
+ "W",
31
+ "N",
32
+ "I",
33
+ "TID251",
34
+ ]
35
+
36
+ [tool.ruff.lint.per-file-ignores]
37
+ "stubs/**" = ["N"]
38
+
39
+ [tool.ruff.lint.flake8-tidy-imports.banned-api."typing.cast"]
40
+ msg = "Narrow with isinstance or fix the type instead of casting."
41
+
42
+ [tool.pytest.ini_options]
43
+ addopts = [
44
+ "-m",
45
+ "not e2e",
46
+ ]
47
+ markers = ["e2e: runs wynd dev from ../wynd; needs uv, Node.js and the network"]
48
+
49
+ [tool.ty.environment]
50
+ extra-paths = ["stubs"]
51
+
52
+ [tool.ty.src]
53
+ exclude = [
54
+ "tests/e2e/entropy",
55
+ "tests/e2e/asgi",
56
+ "tests/e2e/wsgi",
57
+ ]
58
+
59
+ [dependency-groups]
60
+ dev = [
61
+ "pyodide-py>=0.27.8",
62
+ "pytest>=9.1.1",
63
+ ]
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "crosswind-workers"
3
+ version = "0.1.0"
4
+ description = "Typed, Pythonic drop-in replacement for Cloudflare's workers-runtime-sdk"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Viswa Marepalli", email = "reachvishm@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = []
11
+
12
+ [build-system]
13
+ requires = ["uv_build>=0.12.19,<0.13.0"]
14
+ build-backend = "uv_build"
15
+
16
+ [tool.uv.build-backend]
17
+ # The distribution is crosswind-workers, but workerd imports the SDK as `workers`.
18
+ module-name = ["workers", "_crosswind_patches", "asgi"]
19
+ # Lands in site-packages itself, where Python runs .pth files at startup. The
20
+ # hook's name sorts after an editable install's crosswind_workers.pth, which
21
+ # must put src/ on the path first.
22
+ data = { purelib = "purelib" }
23
+
24
+ [tool.ruff.lint]
25
+ extend-select = ["E", "W", "N", "I", "TID251"]
26
+ # Stub names mirror workerd's JS helpers.
27
+ per-file-ignores."stubs/**" = ["N"]
28
+
29
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
30
+ "typing.cast".msg = "Narrow with isinstance or fix the type instead of casting."
31
+
32
+ [tool.pytest.ini_options]
33
+ addopts = ["-m", "not e2e"]
34
+ markers = ["e2e: runs wynd dev from ../wynd; needs uv, Node.js and the network"]
35
+
36
+ [tool.ty.environment]
37
+ # Stubs for modules only workerd provides. They stay out of the wheel.
38
+ extra-paths = ["stubs"]
39
+
40
+ [tool.ty.src]
41
+ # These fixtures' packages exist only in the Pyodide installs their e2e tests
42
+ # make.
43
+ exclude = ["tests/e2e/entropy", "tests/e2e/asgi", "tests/e2e/wsgi"]
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "pyodide-py>=0.27.8",
48
+ "pytest>=9.1.1",
49
+ ]
@@ -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
@@ -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,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}")
@@ -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 {}
@@ -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())