Repository: pyscript/pyscript

Issue:
allow `pyscript.fetch` to be separately awaited
### Discussed in https://github.com/pyscript/pyscript/discussions/1996 and in https://github.com/pyscript/pyscript/discussions/1999

<div type='discussions-op-text'>

<sup>Originally posted by **WebReflection** March 15, 2024</sup>
As soon as we released the simplified `pyscript.fetch` API of course users wanted to do more with it and that's OK.

The issue is that we just provide a single point of *await* with current utility, but in an ideal world we should be able to grant both the shortcut *and* the `response` expectations, just like the module inspiring current API does: https://github.com/WebReflection/fetch#readme

I am not sure how Python can (or can it?) handle this dual facades of the same underlying pipe, but I believe if it's possible in JS it gotta be possible in Python too!

This issue is to make the current utility be able to do both:

```python
from pyscript import fetch

json = await fetch(url).json()
response = await fetch(url)
if response.ok:
  await response.json()
```

If anyone would like to share thoughts or tricks to make this possible, I am all ears!

/cc @JeffersGlass @antocuni </div>


Discussion:

Resolving patch:
diff --git a/pyscript.core/src/stdlib/pyscript/fetch.py b/pyscript.core/src/stdlib/pyscript/fetch.py
--- a/pyscript.core/src/stdlib/pyscript/fetch.py
+++ b/pyscript.core/src/stdlib/pyscript/fetch.py
@@ -3,62 +3,92 @@
 import js
 
 
-def _as_bytearray(buffer):
-    ui8a = js.Uint8Array.new(buffer)
-    size = ui8a.length
-    ba = bytearray(size)
-    for i in range(0, size):
-        ba[i] = ui8a[i]
-    return ba
-
-
-class _Fetch:
-    def __init__(self, url, **kw):
-        # avoid both Pyodide and MicroPython FFI
-        options = js.JSON.parse(json.dumps(kw))
-        self._url = url
-        self._fetch = js.fetch(url, options)
-
-    async def _arrayBuffer(self):
-        response = await self._response()
-        return await response.arrayBuffer()
+### wrap the response to grant Pythonic results
+class _Response:
+    def __init__(self, response):
+        self._response = response
 
-    async def _response(self):
-        response = await self._fetch
-        if not response.ok:
-            msg = f"URL {self._url} failed with status {response.status}"
-            raise Exception(msg)
-        return response
-
-    # https://developer.mozilla.org/en-US/docs/Web/API/Response/arrayBuffer
-    # returns a memoryview of the buffer
+    # grant access to response.ok and other fields
+    def __getattr__(self, attr):
+        return getattr(self._response, attr)
+
+    def _as_bytearray(self, buffer):
+        ui8a = js.Uint8Array.new(buffer)
+        size = ui8a.length
+        ba = bytearray(size)
+        for i in range(0, size):
+            ba[i] = ui8a[i]
+        return ba
+
+    # exposed methods with Pythonic results
     async def arrayBuffer(self):
-        buffer = await self._arrayBuffer()
+        buffer = await self._response.arrayBuffer()
         # works in Pyodide
         if hasattr(buffer, "to_py"):
             return buffer.to_py()
         # shims in MicroPython
-        return memoryview(_as_bytearray(buffer))
+        return memoryview(self._as_bytearray(buffer))
+
+    async def blob(self):
+        return await self._response.blob()
+
+    async def bytearray(self):
+        buffer = await self._response.arrayBuffer()
+        return self._as_bytearray(buffer)
+
+    async def json(self):
+        return json.loads(await self.text())
+
+    async def text(self):
+        return await self._response.text()
+
+
+### allow direct await to _Response methods
+class _DirectResponse:
+    @staticmethod
+    def setup(promise, response):
+        promise._response = _Response(response)
+        return promise._response
+
+    def __init__(self, promise):
+        self._promise = promise
+        promise._response = None
+        promise.arrayBuffer = self.arrayBuffer
+        promise.blob = self.blob
+        promise.bytearray = self.bytearray
+        promise.json = self.json
+        promise.text = self.text
+
+    async def _response(self):
+        if not self._promise._response:
+            await self._promise
+        return self._promise._response
+
+    async def arrayBuffer(self):
+        response = await self._response()
+        return await response.arrayBuffer()
 
-    # https://developer.mozilla.org/en-US/docs/Web/API/Response/blob
     async def blob(self):
         response = await self._response()
         return await response.blob()
 
-    # return a bytearray from the uint8 view of the buffer
     async def bytearray(self):
-        buffer = await self._arrayBuffer()
-        return _as_bytearray(buffer)
+        response = await self._response()
+        return await response.bytearray()
 
-    # https://developer.mozilla.org/en-US/docs/Web/API/Response/json
     async def json(self):
-        return json.loads(await self.text())
+        response = await self._response()
+        return await response.json()
 
-    # https://developer.mozilla.org/en-US/docs/Web/API/Response/text
     async def text(self):
         response = await self._response()
         return await response.text()
 
 
 def fetch(url, **kw):
-    return _Fetch(url, **kw)
+    # workaround Pyodide / MicroPython dict <-> js conversion
+    options = js.JSON.parse(json.dumps(kw))
+    awaited = lambda response, *args: _DirectResponse.setup(promise, response)
+    promise = js.fetch(url, options).then(awaited)
+    _DirectResponse(promise)
+    return promise


Test patch:
diff --git a/pyscript.core/test/fetch.html b/pyscript.core/test/fetch.html
--- a/pyscript.core/test/fetch.html
+++ b/pyscript.core/test/fetch.html
@@ -24,51 +24,71 @@
         );
       </script>
       <script type="mpy" async>
-          import js, json
-          from pyscript import document, fetch
+        import js, json
+        from pyscript import document, fetch
+
+        fetch_text = await (await fetch("config.json")).text()
+        if (fetch_text != js.fetch_text):
+          raise Exception("fetch_text")
+
+        fetch_text = await fetch("config.json").text()
+        if (fetch_text != js.fetch_text):
+          raise Exception("fetch_text")
 
-          fetch_text = await fetch("config.json").text()
-          if (fetch_text != js.fetch_text):
-            raise Exception("fetch_text")
+        fetch_json = await (await fetch("config.json")).json()
+        if (json.dumps(fetch_json).replace(" ", "") != js.fetch_json):
+          raise Exception("fetch_json")
+
+        fetch_json = await fetch("config.json").json()
+        if (json.dumps(fetch_json).replace(" ", "") != js.fetch_json):
+          raise Exception("fetch_json")
 
-          fetch_json = await fetch("config.json").json()
-          if (json.dumps(fetch_json).replace(" ", "") != js.fetch_json):
-            raise Exception("fetch_json")
+        fetch_buffer = await (await fetch("config.json")).arrayBuffer()
+        if (len(fetch_buffer) != js.fetch_buffer):
+          raise Exception("fetch_buffer")
 
-          fetch_buffer = await fetch("config.json").bytearray()
-          if (len(fetch_buffer) != js.fetch_buffer):
-            raise Exception("fetch_buffer")
+        fetch_buffer = await fetch("config.json").arrayBuffer()
+        if (len(fetch_buffer) != js.fetch_buffer):
+          raise Exception("fetch_buffer")
 
-          print(await fetch("config.json").bytearray())
-          print(await fetch("config.json").blob())
+        print(await (await fetch("config.json")).bytearray())
+        print(await (await fetch("config.json")).blob())
 
-          try:
-            await fetch("shenanigans.nope").text()
-          except:
-            document.documentElement.classList.add('mpy')
+        if (await fetch("shenanigans.nope")).ok == False:
+          document.documentElement.classList.add('mpy')
       </script>
       <script type="py" async>
         import js, json
         from pyscript import document, fetch
 
+        fetch_text = await (await fetch("config.json")).text()
+        if (fetch_text != js.fetch_text):
+          raise Exception("fetch_text")
+
         fetch_text = await fetch("config.json").text()
         if (fetch_text != js.fetch_text):
           raise Exception("fetch_text")
 
+        fetch_json = await (await fetch("config.json")).json()
+        if (json.dumps(fetch_json).replace(" ", "") != js.fetch_json):
+          raise Exception("fetch_json")
+
         fetch_json = await fetch("config.json").json()
         if (json.dumps(fetch_json).replace(" ", "") != js.fetch_json):
           raise Exception("fetch_json")
 
-        fetch_buffer = await fetch("config.json").bytearray()
+        fetch_buffer = await (await fetch("config.json")).arrayBuffer()
+        if (len(fetch_buffer) != js.fetch_buffer):
+          raise Exception("fetch_buffer")
+
+        fetch_buffer = await fetch("config.json").arrayBuffer()
         if (len(fetch_buffer) != js.fetch_buffer):
           raise Exception("fetch_buffer")
 
-        print(await fetch("config.json").bytearray())
-        print(await fetch("config.json").blob())
+        print(await (await fetch("config.json")).bytearray())
+        print(await (await fetch("config.json")).blob())
 
-        try:
-          await fetch("shenanigans.nope").text()
-        except:
+        if (await fetch("shenanigans.nope")).ok == False:
           document.documentElement.classList.add('py')
       </script>
     </body>