Skip to content

Commit

Permalink
Merge pull request #2995 from cloudflare/dominik/python-sdk-form-data
Browse files Browse the repository at this point in the history
Implements more methods on Python Response API and adds FormData API.
  • Loading branch information
dom96 authored Oct 28, 2024
2 parents c46b807 + df9f671 commit dfc1edd
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 16 deletions.
1 change: 1 addition & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ ignore = [
"PLR2004", # Magic value used in comparison
"TRY003", # Avoid specifying long messages outside the exception class
"UP038", # Use X | Y in isinstance check instead of (X, Y)
"PLR0911", # too many return statements
]

[lint.per-file-ignores]
Expand Down
144 changes: 134 additions & 10 deletions src/pyodide/internal/workers.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
# 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.
from collections.abc import Generator, MutableMapping
from http import HTTPMethod, HTTPStatus
from typing import TypedDict, Unpack

import js

import pyodide.http
from pyodide.ffi import JsException, to_js
from pyodide.http import pyfetch

JSBody = (
"js.Blob | js.ArrayBuffer | js.TypedArray | js.DataView | js.FormData |"
"js.ReadableStream | js.URLSearchParams"
)
Body = "str | JSBody"
Body = "str | FormData | JSBody"
Headers = dict[str, str] | list[tuple[str, str]]


Expand All @@ -23,16 +25,37 @@ class FetchKwargs(TypedDict, total=False):
method: HTTPMethod = HTTPMethod.GET


# TODO: This is just here to make the `body` field available as many Workers
# examples which rewrite responses make use of it. It's something we should
# likely upstream to Pyodide.
class FetchResponse(pyodide.http.FetchResponse):
# TODO: Consider upstreaming the `body` attribute
@property
def body(self) -> Body:
"""
Returns the body from the JavaScript Response instance.
"""
return self.js_response.body
b = self.js_response.body
if b.constructor.name == "FormData":
return FormData(b)
else:
return b

"""
Instance methods defined below.
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":
try:
return FormData(await self.js_response.formData())
except JsException as exc:
raise _to_python_exception(exc) from exc


async def fetch(
Expand All @@ -45,7 +68,16 @@ async def fetch(
return FetchResponse(resp.url, resp.js_response)


class Response:
def _to_python_exception(exc: JsException) -> Exception:
if exc.name == "RangeError":
return ValueError(exc.message)
elif exc.name == "TypeError":
return TypeError(exc.message)
else:
return exc


class Response(FetchResponse):
def __init__(
self,
body: Body,
Expand All @@ -59,6 +91,19 @@ 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)

# Initialise via the FetchResponse super-class which gives us access to
# methods that we would ordinarily have to redeclare.
js_resp = js.Response.new(
body.js_form_data if isinstance(body, FormData) else body, **options
)
super().__init__(js_resp.url, js_resp)

@staticmethod
def _create_options(
status: HTTPStatus | int = HTTPStatus.OK, statusText="", headers: Headers = None
):
options = {
"status": status.value if isinstance(status, HTTPStatus) else status,
}
Expand All @@ -71,8 +116,87 @@ def __init__(
elif isinstance(headers, dict):
options["headers"] = js.Headers.new(headers.items())
else:
raise TypeError(
"Response() received unexpected type for headers argument"
)
raise TypeError("Received unexpected type for headers argument")

return options

"""
Static methods defined below. The `error` static method is not implemented as
it is not useful for the Workers use case.
"""

@staticmethod
def redirect(url: str, status: HTTPStatus | int = HTTPStatus.FOUND):
code = status.value if isinstance(status, HTTPStatus) else status
try:
return js.Response.redirect(url, code)
except JsException as exc:
raise _to_python_exception(exc) from exc

@staticmethod
def json(
data: str | dict[str, str],
status: HTTPStatus | int = HTTPStatus.OK,
statusText="",
headers: Headers = None,
):
options = Response._create_options(status, statusText, headers)
try:
return js.Response.json(
to_js(data, dict_converter=js.Object.fromEntries), **options
)
except JsException as exc:
raise _to_python_exception(exc) from exc


# TODO: Implement pythonic blob API
FormDataValue = "str | js.Blob"


class FormData(MutableMapping[str, FormDataValue]):
def __init__(self, form_data: "js.FormData | None | dict[str, FormDataValue]"):
if form_data:
if isinstance(form_data, dict):
self.js_form_data = js.FormData.new()
for item in form_data.items():
self.js_form_data.append(item[0], item[1])
else:
self.js_form_data = form_data
else:
self.js_form_data = js.FormData.new()

def __getitem__(self, key: str) -> list[FormDataValue]:
return list(self.js_form_data.getAll(key))

def __setitem__(self, key: str, value: list[FormDataValue]):
self.js_form_data.delete(key)
for item in value:
self.js_form_data.append(key, item)

def append(self, key: str, value: FormDataValue):
self.js_form_data.append(key, value)

def delete(self, key: str):
self.js_form_data.delete(key)

def __contains__(self, key: str) -> bool:
return self.js_form_data.has(key)

def values(self) -> Generator[FormDataValue, None, None]:
yield from self.js_form_data.values()

def keys(self) -> Generator[str, None, None]:
yield from self.js_form_data.keys()

def __iter__(self):
yield from self.keys()

def items(self) -> Generator[tuple[str, FormDataValue], None, None]:
for item in self.js_form_data.entries():
yield (item[0], item[1])

def __delitem__(self, key: str):
self.delete(key)

self.js_response = js.Response.new(body, **options)
def __len__(self):
return len(self.keys())
6 changes: 3 additions & 3 deletions src/workerd/server/tests/python/sdk/sdk.wd-test
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ const python :Workerd.Worker = (
compatibilityFlags = ["python_workers_development", "python_external_bundle"],
);

const mock :Workerd.Worker = (
const server :Workerd.Worker = (
modules = [
(name = "mock.py", pythonModule = embed "mock.py")
(name = "server.py", pythonModule = embed "server.py")
],
compatibilityDate = "2024-10-01",
compatibilityFlags = ["python_workers_development", "python_external_bundle"],
Expand All @@ -25,7 +25,7 @@ const unitTests :Workerd.Config = (
worker = .python
),
( name = "internet",
worker = .mock
worker = .server
)
],
);
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from cloudflare.workers import Response
from cloudflare.workers import FormData, Response


async def on_fetch(request):
Expand All @@ -9,6 +9,11 @@ async def on_fetch(request):
return Response(
"Hi there!", headers={"Custom-Header-That-Should-Passthrough": True}
)
elif request.url.endswith("/redirect"):
return Response.redirect("https://example.com/sub", status=301)
elif request.url.endswith("/formdata"):
data = FormData({"field": "value"})
return Response(data)
else:
raise ValueError("Unexpected path " + request.url)

Expand Down
Loading

0 comments on commit dfc1edd

Please sign in to comment.