Skip to content

Commit 0481b0f

Browse files
committed
Implements Response.replace_body and support for cf opts in Py fetch.
1 parent f1abd6a commit 0481b0f

File tree

3 files changed

+97
-11
lines changed

3 files changed

+97
-11
lines changed

src/pyodide/internal/workers.py

+41-8
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from contextlib import ExitStack, contextmanager
66
from enum import StrEnum
77
from http import HTTPMethod, HTTPStatus
8-
from typing import TypedDict, Unpack
8+
from typing import Any, TypedDict, Unpack
99

1010
import js
1111

@@ -21,10 +21,32 @@
2121
Headers = dict[str, str] | list[tuple[str, str]]
2222

2323

24+
# https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties
25+
class RequestInitCfProperties(TypedDict, total=False):
26+
apps: bool | None
27+
cacheEverything: bool | None
28+
cacheKey: str | None
29+
cacheTags: list[str] | None
30+
cacheTtl: int
31+
cacheTtlByStatus: dict[str, int]
32+
image: (
33+
Any | None
34+
) # TODO: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
35+
mirage: bool | None
36+
polish: str | None
37+
resolveOverride: str | None
38+
scrapeShield: bool | None
39+
webp: bool | None
40+
41+
42+
# This matches the Request options:
43+
# https://developers.cloudflare.com/workers/runtime-apis/request/#options
2444
class FetchKwargs(TypedDict, total=False):
2545
headers: Headers | None
2646
body: "Body | None"
2747
method: HTTPMethod = HTTPMethod.GET
48+
redirect: str | None
49+
cf: RequestInitCfProperties | None
2850

2951

3052
# TODO: Pyodide's FetchResponse.headers returns a dict[str, str] which means
@@ -65,6 +87,15 @@ async def formData(self) -> "FormData":
6587
except JsException as exc:
6688
raise _to_python_exception(exc) from exc
6789

90+
def replace_body(self, body: Body) -> "FetchResponse":
91+
"""
92+
Returns a new Response object with the same options (status, headers, etc) as
93+
the original but with an updated body.
94+
"""
95+
b = body.js_object if isinstance(body, FormData) else body
96+
js_resp = js.Response.new(b, self.js_response)
97+
return FetchResponse(js_resp.url, js_resp)
98+
6899

69100
async def fetch(
70101
resource: str,
@@ -99,7 +130,7 @@ def __init__(
99130
self,
100131
body: Body,
101132
status: HTTPStatus | int = HTTPStatus.OK,
102-
statusText="",
133+
status_text="",
103134
headers: Headers = None,
104135
):
105136
"""
@@ -108,7 +139,7 @@ def __init__(
108139
Based on the JS API of the same name:
109140
https://developer.mozilla.org/en-US/docs/Web/API/Response/Response.
110141
"""
111-
options = self._create_options(status, statusText, headers)
142+
options = self._create_options(status, status_text, headers)
112143

113144
# Initialise via the FetchResponse super-class which gives us access to
114145
# methods that we would ordinarily have to redeclare.
@@ -119,13 +150,15 @@ def __init__(
119150

120151
@staticmethod
121152
def _create_options(
122-
status: HTTPStatus | int = HTTPStatus.OK, statusText="", headers: Headers = None
153+
status: HTTPStatus | int = HTTPStatus.OK,
154+
status_text="",
155+
headers: Headers = None,
123156
):
124157
options = {
125158
"status": status.value if isinstance(status, HTTPStatus) else status,
126159
}
127-
if len(statusText) > 0:
128-
options["statusText"] = statusText
160+
if status_text:
161+
options["statusText"] = status_text
129162
if headers:
130163
if isinstance(headers, list):
131164
# We should have a list[tuple[str, str]]
@@ -154,10 +187,10 @@ def redirect(url: str, status: HTTPStatus | int = HTTPStatus.FOUND):
154187
def json(
155188
data: str | dict[str, str],
156189
status: HTTPStatus | int = HTTPStatus.OK,
157-
statusText="",
190+
status_text="",
158191
headers: Headers = None,
159192
):
160-
options = Response._create_options(status, statusText, headers)
193+
options = Response._create_options(status, status_text, headers)
161194
with _manage_pyproxies() as pyproxies:
162195
try:
163196
return js.Response.json(

src/workerd/io/compatibility-date.capnp

+1-1
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef {
430430
pythonWorkers @43 :Bool
431431
$compatEnableFlag("python_workers")
432432
$pythonSnapshotRelease(pyodide = "0.26.0a2", pyodideRevision = "2024-03-01",
433-
packages = "2024-03-01", backport = 11,
433+
packages = "2024-03-01", backport = 12,
434434
baselineSnapshotHash = "d13ce2f4a0ade2e09047b469874dacf4d071ed3558fec4c26f8d0b99d95f77b5")
435435
$impliedByAfterDate(name = "pythonWorkersDevPyodide", date = "2000-01-01");
436436
# Enables Python Workers. Access to this flag is not restricted, instead bundles containing

src/workerd/server/tests/python/sdk/worker.py

+55-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
1+
from contextlib import asynccontextmanager
12
from http import HTTPMethod, HTTPStatus
23

34
import js
45

6+
import pyodide.http
57
from cloudflare.workers import Blob, File, FormData, Response, fetch
68
from pyodide.ffi import to_js
79

810

11+
@asynccontextmanager
12+
async def _mock_fetch(check):
13+
async def mocked_fetch(original_fetch, url, opts):
14+
check(url, opts)
15+
return await original_fetch(url, opts)
16+
17+
original_fetch = pyodide.http._jsfetch
18+
pyodide.http._jsfetch = lambda url, opts: mocked_fetch(original_fetch, url, opts)
19+
try:
20+
yield
21+
finally:
22+
pyodide.http._jsfetch = original_fetch
23+
24+
925
# Each path in this handler is its own test. The URLs that are being fetched
1026
# here are defined in server.py.
1127
async def on_fetch(request):
@@ -51,8 +67,18 @@ async def on_fetch(request):
5167
elif request.url.endswith("/undefined_opts"):
5268
# This tests two things:
5369
# * `Response.redirect` static method
54-
# * that other options can be passed into `fetch`
55-
resp = await fetch("https://example.com/redirect", redirect="manual")
70+
# * that other options can be passed into `fetch` (so that we can support
71+
# new options without updating this code)
72+
73+
# Mock pyodide.http._jsfetch to ensure `foobarbaz` gets passed in.
74+
def fetch_check(url, opts):
75+
assert opts.foobarbaz == 42
76+
77+
async with _mock_fetch(fetch_check):
78+
resp = await fetch(
79+
"https://example.com/redirect", redirect="manual", foobarbaz=42
80+
)
81+
5682
return resp
5783
elif request.url.endswith("/response_inherited"):
5884
expected = "test123"
@@ -89,6 +115,14 @@ async def on_fetch(request):
89115
assert data["blob.py"].content_type == "text/python"
90116
assert data["metadata"].name == "metadata.json"
91117

118+
return Response("success")
119+
elif request.url.endswith("/cf_opts"):
120+
resp = await fetch(
121+
"http://example.com/redirect",
122+
redirect="manual",
123+
cf={"cacheTtl": 5, "cacheEverything": True, "cacheKey": "someCustomKey"},
124+
)
125+
assert resp.status == 301
92126
return Response("success")
93127
else:
94128
resp = await fetch("https://example.com/sub")
@@ -273,6 +307,23 @@ async def can_request_form_data_blob(env):
273307
assert text == "success"
274308

275309

310+
async def replace_body_unit_tests(env):
311+
response = Response("test", status=201, status_text="Created")
312+
cloned = response.replace_body("other")
313+
assert cloned.status == 201
314+
assert cloned.status_text == "Created"
315+
t = await cloned.text()
316+
assert t == "other"
317+
318+
319+
async def can_use_cf_fetch_opts(env):
320+
response = await env.SELF.fetch(
321+
"http://example.com/cf_opts",
322+
)
323+
text = await response.text()
324+
assert text == "success"
325+
326+
276327
async def test(ctrl, env):
277328
await can_return_custom_fetch_response(env)
278329
await can_modify_response(env)
@@ -287,3 +338,5 @@ async def test(ctrl, env):
287338
await form_data_unit_tests(env)
288339
await blob_unit_tests(env)
289340
await can_request_form_data_blob(env)
341+
await replace_body_unit_tests(env)
342+
await can_use_cf_fetch_opts(env)

0 commit comments

Comments
 (0)