Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implements Response.replace_body and support for cf opts in Py fetch. #3229

Merged
merged 1 commit into from
Jan 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions src/pyodide/internal/workers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from contextlib import ExitStack, contextmanager
from enum import StrEnum
from http import HTTPMethod, HTTPStatus
from typing import TypedDict, Unpack
from typing import Any, TypedDict, Unpack

import js

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


# https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties
class RequestInitCfProperties(TypedDict, total=False):
apps: bool | None
cacheEverything: bool | None
cacheKey: str | None
cacheTags: list[str] | None
cacheTtl: int
cacheTtlByStatus: dict[str, int]
image: (
Any | None
) # TODO: https://developers.cloudflare.com/images/transform-images/transform-via-workers/
mirage: bool | None
polish: str | None
resolveOverride: str | None
scrapeShield: bool | None
webp: bool | None


# This matches the Request options:
# https://developers.cloudflare.com/workers/runtime-apis/request/#options
class FetchKwargs(TypedDict, total=False):
headers: Headers | None
body: "Body | None"
method: HTTPMethod = HTTPMethod.GET
redirect: str | None
cf: RequestInitCfProperties | None


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

def replace_body(self, body: Body) -> "FetchResponse":
"""
Returns a new Response object with the same options (status, headers, etc) as
the original but with an updated body.
"""
b = body.js_object if isinstance(body, FormData) else body
js_resp = js.Response.new(b, self.js_response)
return FetchResponse(js_resp.url, js_resp)


async def fetch(
resource: str,
Expand Down Expand Up @@ -99,7 +130,7 @@ def __init__(
self,
body: Body,
status: HTTPStatus | int = HTTPStatus.OK,
statusText="",
status_text="",
dom96 marked this conversation as resolved.
Show resolved Hide resolved
headers: Headers = None,
):
"""
Expand All @@ -108,7 +139,7 @@ def __init__(
Based on the JS API of the same name:
https://developer.mozilla.org/en-US/docs/Web/API/Response/Response.
"""
options = self._create_options(status, statusText, headers)
options = self._create_options(status, status_text, headers)

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

@staticmethod
def _create_options(
status: HTTPStatus | int = HTTPStatus.OK, statusText="", headers: Headers = None
status: HTTPStatus | int = HTTPStatus.OK,
status_text="",
headers: Headers = None,
):
options = {
"status": status.value if isinstance(status, HTTPStatus) else status,
}
if len(statusText) > 0:
options["statusText"] = statusText
if status_text:
options["statusText"] = status_text
if headers:
if isinstance(headers, list):
# We should have a list[tuple[str, str]]
Expand Down Expand Up @@ -154,10 +187,10 @@ def redirect(url: str, status: HTTPStatus | int = HTTPStatus.FOUND):
def json(
data: str | dict[str, str],
status: HTTPStatus | int = HTTPStatus.OK,
statusText="",
status_text="",
headers: Headers = None,
):
options = Response._create_options(status, statusText, headers)
options = Response._create_options(status, status_text, headers)
with _manage_pyproxies() as pyproxies:
try:
return js.Response.json(
Expand Down
2 changes: 1 addition & 1 deletion src/workerd/io/compatibility-date.capnp
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef {
pythonWorkers @43 :Bool
$compatEnableFlag("python_workers")
$pythonSnapshotRelease(pyodide = "0.26.0a2", pyodideRevision = "2024-03-01",
packages = "2024-03-01", backport = 11,
packages = "2024-03-01", backport = 12,
baselineSnapshotHash = "d13ce2f4a0ade2e09047b469874dacf4d071ed3558fec4c26f8d0b99d95f77b5")
$impliedByAfterDate(name = "pythonWorkersDevPyodide", date = "2000-01-01");
# Enables Python Workers. Access to this flag is not restricted, instead bundles containing
Expand Down
57 changes: 55 additions & 2 deletions src/workerd/server/tests/python/sdk/worker.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
from contextlib import asynccontextmanager
from http import HTTPMethod, HTTPStatus

import js

import pyodide.http
from cloudflare.workers import Blob, File, FormData, Response, fetch
from pyodide.ffi import to_js


@asynccontextmanager
async def _mock_fetch(check):
async def mocked_fetch(original_fetch, url, opts):
check(url, opts)
return await original_fetch(url, opts)

original_fetch = pyodide.http._jsfetch
pyodide.http._jsfetch = lambda url, opts: mocked_fetch(original_fetch, url, opts)
try:
yield
finally:
pyodide.http._jsfetch = original_fetch


# Each path in this handler is its own test. The URLs that are being fetched
# here are defined in server.py.
async def on_fetch(request):
Expand Down Expand Up @@ -51,8 +67,18 @@ async def on_fetch(request):
elif request.url.endswith("/undefined_opts"):
# This tests two things:
# * `Response.redirect` static method
# * that other options can be passed into `fetch`
resp = await fetch("https://example.com/redirect", redirect="manual")
# * that other options can be passed into `fetch` (so that we can support
# new options without updating this code)

# Mock pyodide.http._jsfetch to ensure `foobarbaz` gets passed in.
def fetch_check(url, opts):
assert opts.foobarbaz == 42

async with _mock_fetch(fetch_check):
resp = await fetch(
"https://example.com/redirect", redirect="manual", foobarbaz=42
)

return resp
elif request.url.endswith("/response_inherited"):
expected = "test123"
Expand Down Expand Up @@ -89,6 +115,14 @@ async def on_fetch(request):
assert data["blob.py"].content_type == "text/python"
assert data["metadata"].name == "metadata.json"

return Response("success")
elif request.url.endswith("/cf_opts"):
resp = await fetch(
"http://example.com/redirect",
redirect="manual",
cf={"cacheTtl": 5, "cacheEverything": True, "cacheKey": "someCustomKey"},
)
assert resp.status == 301
return Response("success")
else:
resp = await fetch("https://example.com/sub")
Expand Down Expand Up @@ -273,6 +307,23 @@ async def can_request_form_data_blob(env):
assert text == "success"


async def replace_body_unit_tests(env):
response = Response("test", status=201, status_text="Created")
cloned = response.replace_body("other")
assert cloned.status == 201
assert cloned.status_text == "Created"
t = await cloned.text()
assert t == "other"


async def can_use_cf_fetch_opts(env):
response = await env.SELF.fetch(
"http://example.com/cf_opts",
)
text = await response.text()
assert text == "success"


async def test(ctrl, env):
await can_return_custom_fetch_response(env)
await can_modify_response(env)
Expand All @@ -287,3 +338,5 @@ async def test(ctrl, env):
await form_data_unit_tests(env)
await blob_unit_tests(env)
await can_request_form_data_blob(env)
await replace_body_unit_tests(env)
await can_use_cf_fetch_opts(env)
Loading