Skip to content

Commit f8bd542

Browse files
committed
Implements Request class in Python SDK.
1 parent 91d2ef8 commit f8bd542

File tree

2 files changed

+217
-20
lines changed

2 files changed

+217
-20
lines changed

src/pyodide/internal/workers.py

Lines changed: 166 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# This module defines a Workers API for Python. It is similar to the API provided by
22
# JS Workers, but with changes and additions to be more idiomatic to the Python
33
# programming language.
4+
import http.client
5+
import json
46
from collections.abc import Generator, Iterable, MutableMapping
57
from contextlib import ExitStack, contextmanager
68
from enum import StrEnum
@@ -18,7 +20,7 @@
1820
"js.ReadableStream | js.URLSearchParams"
1921
)
2022
Body = "str | FormData | JSBody"
21-
Headers = dict[str, str] | list[tuple[str, str]]
23+
Headers = "dict[str, str] | list[tuple[str, str]] | js.Headers"
2224

2325

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

6767
@property
6868
def js_object(self) -> "js.Response":
@@ -74,14 +74,16 @@ def js_object(self) -> "js.Response":
7474
Some methods are implemented by `FetchResponse`, these include `buffer`
7575
(replacing JavaScript's `arrayBuffer`), `bytes`, `json`, and `text`.
7676
77-
Some methods are intentionally not implemented, these include `blob`.
78-
7977
There are also some additional methods implemented by `FetchResponse`.
8078
See https://pyodide.org/en/stable/usage/api/python-api/http.html#pyodide.http.FetchResponse
8179
for details.
8280
"""
8381

84-
async def formData(self) -> "FormData":
82+
async def formData(self) -> "FormData": # TODO: Remove after certain compat date.
83+
return await self.form_data()
84+
85+
async def form_data(self) -> "FormData":
86+
self._raise_if_failed()
8587
try:
8688
return FormData(await self.js_response.formData())
8789
except JsException as exc:
@@ -96,6 +98,10 @@ def replace_body(self, body: Body) -> "FetchResponse":
9698
js_resp = js.Response.new(b, self.js_response)
9799
return FetchResponse(js_resp.url, js_resp)
98100

101+
async def blob(self) -> "Blob":
102+
self._raise_if_failed()
103+
return Blob(await self.js_object.blob())
104+
99105

100106
async def fetch(
101107
resource: str,
@@ -125,6 +131,18 @@ def _manage_pyproxies():
125131
destroy_proxies(proxies)
126132

127133

134+
def _to_js_headers(headers: Headers):
135+
if isinstance(headers, list):
136+
# We should have a list[tuple[str, str]]
137+
return js.Headers.new(headers)
138+
elif isinstance(headers, dict):
139+
return js.Headers.new(headers.items())
140+
elif hasattr(headers, "constructor") and headers.constructor.name == "Headers":
141+
return headers
142+
else:
143+
raise TypeError("Received unexpected type for headers argument")
144+
145+
128146
class Response(FetchResponse):
129147
def __init__(
130148
self,
@@ -160,13 +178,7 @@ def _create_options(
160178
if status_text:
161179
options["statusText"] = status_text
162180
if headers:
163-
if isinstance(headers, list):
164-
# We should have a list[tuple[str, str]]
165-
options["headers"] = js.Headers.new(headers)
166-
elif isinstance(headers, dict):
167-
options["headers"] = js.Headers.new(headers.items())
168-
else:
169-
raise TypeError("Received unexpected type for headers argument")
181+
options["headers"] = _to_js_headers(headers)
170182

171183
return options
172184

@@ -457,3 +469,138 @@ def name(self) -> str:
457469
@property
458470
def last_modified(self) -> int:
459471
return self._js_blob.last_modified
472+
473+
474+
class Request:
475+
def __init__(self, input: "Request | str", **other_options: Unpack[FetchKwargs]):
476+
if "method" in other_options and isinstance(
477+
other_options["method"], HTTPMethod
478+
):
479+
other_options["method"] = other_options["method"].value
480+
481+
if "headers" in other_options:
482+
other_options["headers"] = _to_js_headers(other_options["headers"])
483+
self._js_request = js.Request.new(
484+
input._js_request if isinstance(input, Request) else input, **other_options
485+
)
486+
487+
@property
488+
def js_object(self) -> "js.Request":
489+
return self._js_request
490+
491+
# TODO: expose `body` as a native Python stream in the future, follow how we define `Response`
492+
@property
493+
def body(self) -> "js.ReadableStream":
494+
return self.js_object.body
495+
496+
@property
497+
def body_used(self) -> bool:
498+
return self.js_object.bodyUsed
499+
500+
@property
501+
def cache(self) -> str:
502+
return self.js_object.cache
503+
504+
@property
505+
def credentials(self) -> str:
506+
return self.js_object.credentials
507+
508+
@property
509+
def destination(self) -> str:
510+
return self.js_object.destination
511+
512+
@property
513+
def headers(self) -> http.client.HTTPMessage:
514+
result = http.client.HTTPMessage()
515+
516+
for key, val in self.js_object.headers:
517+
for subval in val.split(","):
518+
result[key] = subval.strip()
519+
520+
return result
521+
522+
@property
523+
def integrity(self) -> str:
524+
return self.js_object.integrity
525+
526+
@property
527+
def is_history_navigation(self) -> bool:
528+
return self.js_object.isHistoryNavigation
529+
530+
@property
531+
def keepalive(self) -> bool:
532+
return self.js_object.keepalive
533+
534+
@property
535+
def method(self) -> HTTPMethod:
536+
return HTTPMethod[self.js_object.method]
537+
538+
@property
539+
def mode(self) -> str:
540+
return self.js_object.mode
541+
542+
@property
543+
def redirect(self) -> str:
544+
return self.js_object.redirect
545+
546+
@property
547+
def referrer(self) -> str:
548+
return self.js_object.referrer
549+
550+
@property
551+
def referrer_policy(self) -> str:
552+
return self.js_object.referrerPolicy
553+
554+
@property
555+
def url(self) -> str:
556+
return self.js_object.url
557+
558+
def _raise_if_failed(self) -> None:
559+
# TODO: https://github.com/pyodide/pyodide/blob/a53c17fd8/src/py/pyodide/http.py#L252
560+
if self.body_used:
561+
# TODO: Use BodyUsedError in newer Pyodide versions.
562+
raise OSError("Body already used")
563+
564+
"""
565+
Instance methods defined below.
566+
567+
The naming of these methods should match Request's methods when possible.
568+
569+
TODO: AbortController support.
570+
"""
571+
572+
async def buffer(self) -> "js.ArrayBuffer":
573+
# The naming of this method matches that of Response.
574+
self._raise_if_failed()
575+
return await self.js_object.arrayBuffer()
576+
577+
async def form_data(self) -> "FormData":
578+
self._raise_if_failed()
579+
try:
580+
return FormData(await self.js_object.formData())
581+
except JsException as exc:
582+
raise _to_python_exception(exc) from exc
583+
584+
async def blob(self) -> Blob:
585+
self._raise_if_failed()
586+
return Blob(await self.js_object.blob())
587+
588+
async def bytes(self) -> bytes:
589+
self._raise_if_failed()
590+
return (await self.buffer()).to_bytes()
591+
592+
def clone(self) -> "Request":
593+
if self.body_used:
594+
# TODO: Use BodyUsedError in newer Pyodide versions.
595+
raise OSError("Body already used")
596+
return Request(
597+
self.js_object.clone(),
598+
)
599+
600+
async def json(self, **kwargs: Any) -> Any:
601+
self._raise_if_failed()
602+
return json.loads(await self.text(), **kwargs)
603+
604+
async def text(self) -> str:
605+
self._raise_if_failed()
606+
return await self.js_object.text()

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from http import HTTPMethod, HTTPStatus
33

44
import js
5-
from workers import Blob, File, FormData, Response, fetch
5+
from workers import Blob, File, FormData, Request, Response, fetch
66

77
import pyodide.http
88
from pyodide.ffi import to_js
@@ -324,6 +324,55 @@ async def can_use_cf_fetch_opts(env):
324324
assert text == "success"
325325

326326

327+
async def request_unit_tests(env):
328+
req = Request("https://test.com", method=HTTPMethod.POST)
329+
assert req.method == HTTPMethod.POST
330+
331+
# Verify that we can pass JS headers to Request
332+
js_headers = js.Headers.new()
333+
js_headers.set("foo", "bar")
334+
req_with_headers = Request("http://example.com", headers=js_headers)
335+
assert req_with_headers.headers["foo"] == "bar"
336+
337+
# Verify that we can pass a dictionary as headers to Request
338+
req_with_headers = Request("http://example.com", headers={"aaaa": "test"})
339+
assert req_with_headers.headers["aaaa"] == "test"
340+
341+
# Verify that BodyUserError is thrown correctly.
342+
req_used_twice = Request(
343+
"http://example.com", body='{"field": 42}', method=HTTPMethod.POST
344+
)
345+
data = await req_used_twice.json()
346+
assert data["field"] == 42
347+
try:
348+
req_used_twice.clone()
349+
raise ValueError("Expected to throw") # noqa: TRY301
350+
except Exception as exc:
351+
assert exc.__class__.__name__ == "OSError" # TODO: BodyUsedError when available
352+
353+
# Verify that duplicate header keys are returned correctly.
354+
js_headers = js.Headers.new()
355+
js_headers.append("Accept-encoding", "deflate")
356+
js_headers.append("Accept-encoding", "gzip")
357+
req_with_dup_headers = Request("http://example.com", headers=js_headers)
358+
assert req_with_dup_headers.url == "http://example.com/"
359+
encoding = req_with_dup_headers.headers.get_all("Accept-encoding")
360+
assert "deflate" in encoding
361+
assert "gzip" in encoding
362+
363+
# Verify that we can get a Blob.
364+
req_for_blob = Request("http://example.com", body="foobar", method="POST")
365+
blob = await req_for_blob.blob()
366+
assert (await blob.text()) == "foobar"
367+
368+
# Verify that we can get a FormData back.
369+
js_form_data = js.FormData.new()
370+
js_form_data.append("foobar", 123)
371+
req_with_form_data = Request("http://example.com", body=js_form_data, method="POST")
372+
form_data = await req_with_form_data.form_data()
373+
assert form_data["foobar"] == "123"
374+
375+
327376
async def test(ctrl, env):
328377
await can_return_custom_fetch_response(env)
329378
await can_modify_response(env)
@@ -340,3 +389,4 @@ async def test(ctrl, env):
340389
await can_request_form_data_blob(env)
341390
await replace_body_unit_tests(env)
342391
await can_use_cf_fetch_opts(env)
392+
await request_unit_tests(env)

0 commit comments

Comments
 (0)