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
57from http import HTTPMethod , HTTPStatus
68from typing import TypedDict , Unpack
79
810import js
911
1012import pyodide .http
11- from pyodide .ffi import JsException , to_js
13+ from pyodide .ffi import JsException , create_proxy , destroy_proxies , to_js
1214from pyodide .http import pyfetch
1315
1416JSBody = (
@@ -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
2832class 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+
8097class 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
0 commit comments