Skip to content

Commit

Permalink
Implements Request class in Python SDK.
Browse files Browse the repository at this point in the history
  • Loading branch information
dom96 committed Mar 11, 2025
1 parent 91d2ef8 commit 4ee8945
Show file tree
Hide file tree
Showing 2 changed files with 190 additions and 19 deletions.
179 changes: 161 additions & 18 deletions src/pyodide/internal/workers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# This module defines a Workers API for Python. It is similar to the API provided by
# JS Workers, but with changes and additions to be more idiomatic to the Python
# programming language.
import http.client
import json
from collections.abc import Generator, Iterable, MutableMapping
from contextlib import ExitStack, contextmanager
from enum import StrEnum
Expand All @@ -18,7 +20,7 @@
"js.ReadableStream | js.URLSearchParams"
)
Body = "str | FormData | JSBody"
Headers = dict[str, str] | list[tuple[str, str]]
Headers = "dict[str, str] | list[tuple[str, str]] | js.Headers"


# https://developers.cloudflare.com/workers/runtime-apis/request/#the-cf-property-requestinitcfproperties
Expand All @@ -42,7 +44,7 @@ class RequestInitCfProperties(TypedDict, total=False):
# This matches the Request options:
# https://developers.cloudflare.com/workers/runtime-apis/request/#options
class FetchKwargs(TypedDict, total=False):
headers: Headers | None
headers: "Headers | None"
body: "Body | None"
method: HTTPMethod = HTTPMethod.GET
redirect: str | None
Expand All @@ -53,16 +55,14 @@ class FetchKwargs(TypedDict, total=False):
# duplicates are lost, we should fix that so it returns a http.client.HTTPMessage
class FetchResponse(pyodide.http.FetchResponse):
# TODO: Consider upstreaming the `body` attribute
# TODO: Behind a compat flag make this return a native stream (StreamReader?), or perhaps
# behind a different name, maybe `stream`?
@property
def body(self) -> Body:
def body(self) -> "js.ReadableStream":
"""
Returns the body from the JavaScript Response instance.
Returns the body as a JavaScript ReadableStream from the JavaScript Response instance.
"""
b = self.js_response.body
if b.constructor.name == "FormData":
return FormData(b)
else:
return b
return self.js_response.body

@property
def js_object(self) -> "js.Response":
Expand All @@ -74,14 +74,13 @@ def js_object(self) -> "js.Response":
Some methods are implemented by `FetchResponse`, these include `buffer`
(replacing JavaScript's `arrayBuffer`), `bytes`, `json`, and `text`.
Some methods are intentionally not implemented, these include `blob`.
There are also some additional methods implemented by `FetchResponse`.
See https://pyodide.org/en/stable/usage/api/python-api/http.html#pyodide.http.FetchResponse
for details.
"""

async def formData(self) -> "FormData":
self._raise_if_failed()
try:
return FormData(await self.js_response.formData())
except JsException as exc:
Expand All @@ -96,6 +95,10 @@ def replace_body(self, body: Body) -> "FetchResponse":
js_resp = js.Response.new(b, self.js_response)
return FetchResponse(js_resp.url, js_resp)

async def blob(self) -> "Blob":
self._raise_if_failed()
return _js_value_to_py(await self.js_object.blob())


async def fetch(
resource: str,
Expand Down Expand Up @@ -125,6 +128,18 @@ def _manage_pyproxies():
destroy_proxies(proxies)


def _to_js_headers(headers: Headers):
if isinstance(headers, list):
# We should have a list[tuple[str, str]]
return js.Headers.new(headers)
elif isinstance(headers, dict):
return js.Headers.new(headers.items())
elif hasattr(headers, "constructor") and headers.constructor.name == "Headers":
return headers
else:
raise TypeError("Received unexpected type for headers argument")


class Response(FetchResponse):
def __init__(
self,
Expand Down Expand Up @@ -160,13 +175,7 @@ def _create_options(
if status_text:
options["statusText"] = status_text
if headers:
if isinstance(headers, list):
# We should have a list[tuple[str, str]]
options["headers"] = js.Headers.new(headers)
elif isinstance(headers, dict):
options["headers"] = js.Headers.new(headers.items())
else:
raise TypeError("Received unexpected type for headers argument")
options["headers"] = _to_js_headers(headers)

return options

Expand Down Expand Up @@ -457,3 +466,137 @@ def name(self) -> str:
@property
def last_modified(self) -> int:
return self._js_blob.last_modified


class Request:
def __init__(self, input: "Request | str", **other_options: Unpack[FetchKwargs]):
if "method" in other_options and isinstance(
other_options["method"], HTTPMethod
):
other_options["method"] = other_options["method"].value

if "headers" in other_options:
other_options["headers"] = _to_js_headers(other_options["headers"])
self._js_request = js.Request.new(
input._js_request if isinstance(input, Request) else input, **other_options
)

@property
def js_object(self) -> "js.Request":
return self._js_request

# TODO: expose `body` as a native Python stream in the future, follow how we define `Response`
@property
def body(self) -> "js.ReadableStream":
return self.js_object.body

@property
def body_used(self) -> bool:
return self.js_object.bodyUsed

@property
def cache(self) -> str:
return self.js_object.cache

@property
def credentials(self) -> str:
return self.js_object.credentials

@property
def destination(self) -> str:
return self.js_object.destination

@property
def headers(self) -> http.client.HTTPMessage:
result = http.client.HTTPMessage()

for entry in self.js_object.headers:
result[entry[0]] = entry[1]

return result

@property
def integrity(self) -> str:
return self.js_object.integrity

@property
def is_history_navigation(self) -> bool:
return self.js_object.isHistoryNavigation

@property
def keepalive(self) -> bool:
return self.js_object.keepalive

@property
def method(self) -> HTTPMethod:
return HTTPMethod[self.js_object.method]

@property
def mode(self) -> str:
return self.js_object.mode

@property
def redirect(self) -> str:
return self.js_object.redirect

@property
def referrer(self) -> str:
return self.js_object.referrer

@property
def referrer_policy(self) -> str:
return self.js_object.referrerPolicy

@property
def url(self) -> str:
return self.js_object.url

def _raise_if_failed(self) -> None:
# TODO: https://github.com/pyodide/pyodide/blob/a53c17fd8/src/py/pyodide/http.py#L252
if self.body_used:
# TODO: Use BodyUsedError in newer Pyodide versions.
raise OSError("Body already used")

"""
Instance methods defined below.
The naming of these methods should match Request's methods when possible.
TODO: AbortController support.
"""

async def buffer(self) -> "js.ArrayBuffer":
# The naming of this method matches that of Response.
self._raise_if_failed()
return await self.js_object.arrayBuffer()

async def formData(self) -> "FormData":
self._raise_if_failed()
try:
return FormData(await self.js_object.formData())
except JsException as exc:
raise _to_python_exception(exc) from exc

async def blob(self) -> Blob:
self._raise_if_failed()
return _js_value_to_py(await self.js_object.blob())

async def bytes(self) -> bytes:
self._raise_if_failed()
return (await self.buffer()).to_bytes()

def clone(self) -> "Request":
if self.body_used:
# TODO: Use BodyUsedError in newer Pyodide versions.
raise OSError("Body already used")
return Request(
self.js_object.clone(),
)

async def json(self, **kwargs: Any) -> Any:
self._raise_if_failed()
return json.loads(await self.text(), **kwargs)

async def text(self) -> str:
self._raise_if_failed()
return await self.js_object.text()
30 changes: 29 additions & 1 deletion src/workerd/server/tests/python/sdk/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from http import HTTPMethod, HTTPStatus

import js
from workers import Blob, File, FormData, Response, fetch
from workers import Blob, File, FormData, Request, Response, fetch

import pyodide.http
from pyodide.ffi import to_js
Expand Down Expand Up @@ -324,6 +324,33 @@ async def can_use_cf_fetch_opts(env):
assert text == "success"


async def request_unit_tests(env):
req = Request("https://test.com", method=HTTPMethod.POST)
assert req.method == HTTPMethod.POST

# Verify that we can pass JS headers to Request
js_headers = js.Headers.new()
js_headers.set("foo", "bar")
req_with_headers = Request("http://example.com", headers=js_headers)
assert req_with_headers.headers["foo"] == "bar"

# Verify that we can pass a dictionary as headers to Request
req_with_headers = Request("http://example.com", headers={"aaaa": "test"})
assert req_with_headers.headers["aaaa"] == "test"

# Verify that BodyUserError is thrown correctly.
req_used_twice = Request(
"http://example.com", body='{"field": 42}', method=HTTPMethod.POST
)
data = await req_used_twice.json()
assert data["field"] == 42
try:
req_used_twice.clone()
raise ValueError("Expected to throw") # noqa: TRY301
except Exception as exc:
assert exc.__class__.__name__ == "OSError" # TODO: BodyUsedError when available


async def test(ctrl, env):
await can_return_custom_fetch_response(env)
await can_modify_response(env)
Expand All @@ -340,3 +367,4 @@ async def test(ctrl, env):
await can_request_form_data_blob(env)
await replace_body_unit_tests(env)
await can_use_cf_fetch_opts(env)
await request_unit_tests(env)

0 comments on commit 4ee8945

Please sign in to comment.