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.
- crosswind_workers-0.1.0/PKG-INFO +9 -0
- crosswind_workers-0.1.0/README.md +0 -0
- crosswind_workers-0.1.0/purelib/crosswind_workers_patches.pth +1 -0
- crosswind_workers-0.1.0/pyproject.toml +63 -0
- crosswind_workers-0.1.0/pyproject.toml.orig +49 -0
- crosswind_workers-0.1.0/src/_crosswind_patches/__init__.py +13 -0
- crosswind_workers-0.1.0/src/_crosswind_patches/entropy.py +259 -0
- crosswind_workers-0.1.0/src/_crosswind_patches/runtime.py +45 -0
- crosswind_workers-0.1.0/src/asgi/__init__.py +7 -0
- crosswind_workers-0.1.0/src/workers/__init__.py +88 -0
- crosswind_workers-0.1.0/src/workers/_adapter.py +24 -0
- crosswind_workers-0.1.0/src/workers/_body.py +34 -0
- crosswind_workers-0.1.0/src/workers/_js.py +121 -0
- crosswind_workers-0.1.0/src/workers/asgi.py +523 -0
- crosswind_workers-0.1.0/src/workers/bindings/__init__.py +36 -0
- crosswind_workers-0.1.0/src/workers/bindings/base.py +135 -0
- crosswind_workers-0.1.0/src/workers/bindings/d1.py +47 -0
- crosswind_workers-0.1.0/src/workers/bindings/env.py +50 -0
- crosswind_workers-0.1.0/src/workers/bindings/kv.py +121 -0
- crosswind_workers-0.1.0/src/workers/bindings/queues.py +36 -0
- crosswind_workers-0.1.0/src/workers/bindings/r2.py +130 -0
- crosswind_workers-0.1.0/src/workers/bindings/services.py +55 -0
- crosswind_workers-0.1.0/src/workers/bindings/workflow.py +64 -0
- crosswind_workers-0.1.0/src/workers/blob.py +158 -0
- crosswind_workers-0.1.0/src/workers/context.py +230 -0
- crosswind_workers-0.1.0/src/workers/entrypoints.py +183 -0
- crosswind_workers-0.1.0/src/workers/fetch.py +51 -0
- crosswind_workers-0.1.0/src/workers/formdata.py +92 -0
- crosswind_workers-0.1.0/src/workers/py.typed +0 -0
- crosswind_workers-0.1.0/src/workers/request.py +138 -0
- crosswind_workers-0.1.0/src/workers/response.py +181 -0
- crosswind_workers-0.1.0/src/workers/rpc.py +154 -0
- crosswind_workers-0.1.0/src/workers/types.py +57 -0
- crosswind_workers-0.1.0/src/workers/utils.py +55 -0
- crosswind_workers-0.1.0/src/workers/workflows.py +265 -0
- 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,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())
|