Skip to content

Commit 186655e

Browse files
authored
Merge pull request #3096 from cloudflare/dominik/blob-api
Implements JS Blob API in Python SDK
2 parents 2115004 + a75d9dc commit 186655e

File tree

3 files changed

+335
-42
lines changed

3 files changed

+335
-42
lines changed

src/pyodide/internal/workers.py

Lines changed: 259 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
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-
from collections.abc import Generator, MutableMapping
4+
from collections.abc import Generator, Iterable, MutableMapping
5+
from contextlib import ExitStack, contextmanager
6+
from enum import StrEnum
57
from http import HTTPMethod, HTTPStatus
68
from typing import TypedDict, Unpack
79

810
import js
911

1012
import pyodide.http
11-
from pyodide.ffi import JsException, to_js
13+
from pyodide.ffi import JsException, create_proxy, destroy_proxies, to_js
1214
from pyodide.http import pyfetch
1315

1416
JSBody = (
@@ -25,6 +27,8 @@ class FetchKwargs(TypedDict, total=False):
2527
method: HTTPMethod = HTTPMethod.GET
2628

2729

30+
# TODO: Pyodide's FetchResponse.headers returns a dict[str, str] which means
31+
# duplicates are lost, we should fix that so it returns a http.client.HTTPMessage
2832
class FetchResponse(pyodide.http.FetchResponse):
2933
# TODO: Consider upstreaming the `body` attribute
3034
@property
@@ -38,6 +42,10 @@ def body(self) -> Body:
3842
else:
3943
return b
4044

45+
@property
46+
def js_object(self) -> "js.Response":
47+
return self.js_response
48+
4149
"""
4250
Instance methods defined below.
4351
@@ -77,6 +85,15 @@ def _to_python_exception(exc: JsException) -> Exception:
7785
return exc
7886

7987

88+
@contextmanager
89+
def _manage_pyproxies():
90+
proxies = js.Array.new()
91+
try:
92+
yield proxies
93+
finally:
94+
destroy_proxies(proxies)
95+
96+
8097
class Response(FetchResponse):
8198
def __init__(
8299
self,
@@ -96,7 +113,7 @@ def __init__(
96113
# Initialise via the FetchResponse super-class which gives us access to
97114
# methods that we would ordinarily have to redeclare.
98115
js_resp = js.Response.new(
99-
body.js_form_data if isinstance(body, FormData) else body, **options
116+
body.js_object if isinstance(body, FormData) else body, **options
100117
)
101118
super().__init__(js_resp.url, js_resp)
102119

@@ -141,62 +158,269 @@ def json(
141158
headers: Headers = None,
142159
):
143160
options = Response._create_options(status, statusText, headers)
144-
try:
145-
return js.Response.json(
146-
to_js(data, dict_converter=js.Object.fromEntries), **options
147-
)
148-
except JsException as exc:
149-
raise _to_python_exception(exc) from exc
161+
with _manage_pyproxies() as pyproxies:
162+
try:
163+
return js.Response.json(
164+
to_js(
165+
data, dict_converter=js.Object.fromEntries, pyproxies=pyproxies
166+
),
167+
**options,
168+
)
169+
except JsException as exc:
170+
raise _to_python_exception(exc) from exc
150171

151172

152-
# TODO: Implement pythonic blob API
153-
FormDataValue = "str | js.Blob"
173+
FormDataValue = "str | js.Blob | Blob"
154174

155175

156-
class FormData(MutableMapping[str, FormDataValue]):
157-
def __init__(self, form_data: "js.FormData | None | dict[str, FormDataValue]"):
158-
if form_data:
159-
if isinstance(form_data, dict):
160-
self.js_form_data = js.FormData.new()
161-
for item in form_data.items():
162-
self.js_form_data.append(item[0], item[1])
163-
else:
164-
self.js_form_data = form_data
176+
def _py_value_to_js(item: FormDataValue) -> "str | js.Blob":
177+
if isinstance(item, Blob):
178+
return item.js_object
179+
else:
180+
return item
181+
182+
183+
def _js_value_to_py(item: FormDataValue) -> "str | Blob | File":
184+
if hasattr(item, "constructor") and (item.constructor.name in ("Blob", "File")):
185+
if item.constructor.name == "File":
186+
return File(item, item.name)
165187
else:
166-
self.js_form_data = js.FormData.new()
188+
return Blob(item)
189+
else:
190+
return item
191+
192+
193+
class FormData(MutableMapping[str, FormDataValue]):
194+
"""
195+
This API follows that of https://pypi.org/project/multidict/.
196+
"""
197+
198+
def __init__(
199+
self, form_data: "js.FormData | None | dict[str, FormDataValue]" = None
200+
):
201+
if not form_data:
202+
self._js_form_data = js.FormData.new()
203+
return
204+
205+
if isinstance(form_data, dict):
206+
self._js_form_data = js.FormData.new()
207+
for k, v in form_data.items():
208+
self._js_form_data.append(k, _py_value_to_js(v))
209+
return
210+
211+
if (
212+
hasattr(form_data, "constructor")
213+
and form_data.constructor.name == "FormData"
214+
):
215+
self._js_form_data = form_data
216+
return
167217

168-
def __getitem__(self, key: str) -> list[FormDataValue]:
169-
return list(self.js_form_data.getAll(key))
218+
raise TypeError("Expected form_data to be a dict or an instance of FormData")
170219

171-
def __setitem__(self, key: str, value: list[FormDataValue]):
172-
self.js_form_data.delete(key)
173-
for item in value:
174-
self.js_form_data.append(key, item)
220+
def __getitem__(self, key: str) -> FormDataValue:
221+
return _js_value_to_py(self._js_form_data.get(key))
175222

176-
def append(self, key: str, value: FormDataValue):
177-
self.js_form_data.append(key, value)
223+
def __setitem__(self, key: str, value: FormDataValue):
224+
if isinstance(value, list):
225+
raise TypeError("Expected single item in arguments to FormData.__setitem__")
226+
self._js_form_data.set(key, _py_value_to_js(value))
227+
228+
def append(self, key: str, value: FormDataValue, filename: str | None = None):
229+
self._js_form_data.append(key, _py_value_to_js(value), filename)
178230

179231
def delete(self, key: str):
180-
self.js_form_data.delete(key)
232+
self._js_form_data.delete(key)
181233

182234
def __contains__(self, key: str) -> bool:
183-
return self.js_form_data.has(key)
235+
return self._js_form_data.has(key)
184236

185237
def values(self) -> Generator[FormDataValue, None, None]:
186-
yield from self.js_form_data.values()
238+
for val in self._js_form_data.values():
239+
yield _js_value_to_py(val)
187240

188241
def keys(self) -> Generator[str, None, None]:
189-
yield from self.js_form_data.keys()
242+
yield from self._js_form_data.keys()
190243

191244
def __iter__(self):
192245
yield from self.keys()
193246

194247
def items(self) -> Generator[tuple[str, FormDataValue], None, None]:
195-
for item in self.js_form_data.entries():
196-
yield (item[0], item[1])
248+
for k, v in self._js_form_data.entries():
249+
yield (k, _js_value_to_py(v))
197250

198251
def __delitem__(self, key: str):
199252
self.delete(key)
200253

201254
def __len__(self):
202255
return len(self.keys())
256+
257+
def get_all(self, key: str) -> list[FormDataValue]:
258+
return [_js_value_to_py(x) for x in self._js_form_data.getAll(key)]
259+
260+
@property
261+
def js_object(self) -> "js.FormData":
262+
return self._js_form_data
263+
264+
265+
def _supports_buffer_protocol(o):
266+
try:
267+
# memoryview used only for testing type; 'with' releases the view instantly
268+
with memoryview(o):
269+
return True
270+
except TypeError:
271+
return False
272+
273+
274+
@contextmanager
275+
def _make_blob_entry(e):
276+
if isinstance(e, str):
277+
yield e
278+
return
279+
if isinstance(e, Blob):
280+
yield e._js_blob
281+
return
282+
if hasattr(e, "constructor") and (e.constructor.name in ("Blob", "File")):
283+
yield e
284+
return
285+
if _supports_buffer_protocol(e):
286+
px = create_proxy(e)
287+
buf = px.getBuffer()
288+
try:
289+
yield buf.data
290+
return
291+
finally:
292+
buf.release()
293+
px.destroy()
294+
raise TypeError(f"Don't know how to handle {type(e)} for Blob()")
295+
296+
297+
def _is_iterable(obj):
298+
try:
299+
iter(obj)
300+
except TypeError:
301+
return False
302+
else:
303+
return True
304+
305+
306+
BlobValue = (
307+
"str | bytes | js.ArrayBuffer | js.TypedArray | js.DataView | js.Blob | Blob | File"
308+
)
309+
310+
311+
class BlobEnding(StrEnum):
312+
TRANSPARENT = "transparent"
313+
NATIVE = "native"
314+
315+
316+
class Blob:
317+
def __init__(
318+
self,
319+
blob_parts: "Iterable[BlobValue] | BlobValue",
320+
content_type: str | None = None,
321+
endings: BlobEnding | str | None = None,
322+
):
323+
if endings:
324+
endings = str(endings)
325+
326+
is_single_item = not _is_iterable(blob_parts)
327+
if is_single_item:
328+
# Inherit the content_type if we have a single item. If a File is passed
329+
# in then its metadata is lost.
330+
if not content_type and isinstance(blob_parts, Blob):
331+
content_type = blob_parts.content_type
332+
if hasattr(blob_parts, "constructor") and (
333+
blob_parts.constructor.name in ("Blob", "File")
334+
):
335+
if not content_type:
336+
content_type = blob_parts.type
337+
338+
# Otherwise create a new Blob below.
339+
blob_parts = [blob_parts]
340+
341+
with ExitStack() as stack:
342+
args = [stack.enter_context(_make_blob_entry(e)) for e in blob_parts]
343+
with _manage_pyproxies() as pyproxies:
344+
self._js_blob = js.Blob.new(
345+
to_js(args, pyproxies=pyproxies),
346+
type=content_type,
347+
endings=endings,
348+
)
349+
350+
@property
351+
def size(self) -> int:
352+
return self._js_blob.size
353+
354+
@property
355+
def content_type(self) -> str:
356+
return self._js_blob.type
357+
358+
@property
359+
def js_object(self) -> "js.Blob":
360+
return self._js_blob
361+
362+
async def text(self) -> str:
363+
return await self.js_object.text()
364+
365+
async def bytes(self) -> bytes:
366+
return (await self.js_object.arrayBuffer()).to_bytes()
367+
368+
def slice(
369+
self,
370+
start: int | None = None,
371+
end: int | None = None,
372+
content_type: str | None = None,
373+
):
374+
return self.js_object.slice(start, end, content_type)
375+
376+
377+
class File(Blob):
378+
def __init__(
379+
self,
380+
blob_parts: "Iterable[BlobValue] | BlobValue",
381+
filename: str,
382+
content_type: str | None = None,
383+
endings: BlobEnding | str | None = None,
384+
last_modified: int | None = None,
385+
):
386+
if endings:
387+
endings = str(endings)
388+
389+
is_single_item = not _is_iterable(blob_parts)
390+
if is_single_item:
391+
# Inherit the content_type and lastModified if we have a
392+
# single item.
393+
if not content_type and isinstance(blob_parts, Blob):
394+
content_type = blob_parts.content_type
395+
if not last_modified and isinstance(blob_parts, File):
396+
last_modified = blob_parts.last_modified
397+
if hasattr(blob_parts, "constructor") and (
398+
blob_parts.constructor.name in ("Blob", "File")
399+
):
400+
if not content_type:
401+
content_type = blob_parts.type
402+
if blob_parts.constructor.name == "File":
403+
if not last_modified:
404+
last_modified = blob_parts.lastModified
405+
406+
# Otherwise create a new File below.
407+
blob_parts = [blob_parts]
408+
409+
with ExitStack() as stack:
410+
args = [stack.enter_context(_make_blob_entry(e)) for e in blob_parts]
411+
with _manage_pyproxies() as pyproxies:
412+
self._js_blob = js.File.new(
413+
to_js(args, pyproxies=pyproxies),
414+
filename,
415+
type=content_type,
416+
endings=endings,
417+
lastModified=last_modified,
418+
)
419+
420+
@property
421+
def name(self) -> str:
422+
return self._js_blob.name
423+
424+
@property
425+
def last_modified(self) -> int:
426+
return self._js_blob.last_modified

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from cloudflare.workers import FormData, Response
1+
from cloudflare.workers import Blob, FormData, Response
22

33

44
async def on_fetch(request):
@@ -14,6 +14,13 @@ async def on_fetch(request):
1414
elif request.url.endswith("/formdata"):
1515
data = FormData({"field": "value"})
1616
return Response(data)
17+
elif request.url.endswith("/formdatablob"):
18+
data = FormData({"field": "value"})
19+
data["blob.py"] = Blob("print(42)", content_type="text/python")
20+
data.append(
21+
"metadata", Blob("{}", content_type="text/python"), filename="metadata.json"
22+
)
23+
return Response(data)
1724
else:
1825
raise ValueError("Unexpected path " + request.url)
1926

0 commit comments

Comments
 (0)