Repository: pyscript/pyscript

Issue:
Add a `fetch` function to the `pyscript` namespace in the "standard library"
### Discussed in https://github.com/pyscript/pyscript/discussions/1987

Provide a way to simplify `fetch` operations through our `pyscript` namespace so that:

  * `await fetch(url).text()` returns the text from the endpoint
  * `await fetch(url).json()` returns a Python `json.loads` of the response text
  * `await fetch(url).arrayBuffer()` returns a `memoryview` of the underlying data
  * `await fetch(url).bytearray()` returns a `bytearray` of the loaded data
  * `await fetch(url).blob()` returns a *Blob* **as it is** to consume it via JS if desired/necessary

/cc @ntoll 


Discussion:

Resolving patch:
diff --git a/pyscript.core/src/stdlib/pyscript/__init__.py b/pyscript.core/src/stdlib/pyscript/__init__.py
--- a/pyscript.core/src/stdlib/pyscript/__init__.py
+++ b/pyscript.core/src/stdlib/pyscript/__init__.py
@@ -30,6 +30,7 @@
 #     as it works transparently in both the main thread and worker cases.
 
 from pyscript.display import HTML, display
+from pyscript.fetch import fetch
 from pyscript.magic_js import (
     RUNNING_IN_WORKER,
     PyWorker,
diff --git a/pyscript.core/src/stdlib/pyscript/fetch.py b/pyscript.core/src/stdlib/pyscript/fetch.py
new file mode 100644
--- /dev/null
+++ b/pyscript.core/src/stdlib/pyscript/fetch.py
@@ -0,0 +1,64 @@
+import json
+
+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()
+
+    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
+    async def arrayBuffer(self):
+        buffer = await self._arrayBuffer()
+        # works in Pyodide
+        if hasattr(buffer, "to_py"):
+            return buffer.to_py()
+        # shims in MicroPython
+        return memoryview(_as_bytearray(buffer))
+
+    # 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)
+
+    # https://developer.mozilla.org/en-US/docs/Web/API/Response/json
+    async def json(self):
+        return json.loads(await self.text())
+
+    # 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)


Test patch:
diff --git a/pyscript.core/test/fetch.html b/pyscript.core/test/fetch.html
new file mode 100644
--- /dev/null
+++ b/pyscript.core/test/fetch.html
@@ -0,0 +1,75 @@
+<!doctype html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8" />
+        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+        <link rel="stylesheet" href="../dist/core.css">
+    </head>
+    <body>
+      <script type="module">
+        import fetch from 'https://esm.run/@webreflection/fetch';
+
+        globalThis.fetch_text = await fetch("config.json").text();
+        globalThis.fetch_json = JSON.stringify(await fetch("config.json").json());
+        globalThis.fetch_buffer = new Uint8Array((await fetch("config.json").arrayBuffer())).length;
+
+        document.head.appendChild(
+          Object.assign(
+            document.createElement('script'),
+            {
+              type: 'module',
+              src: '../dist/core.js'
+            }
+          )
+        );
+      </script>
+      <script type="mpy" async>
+          import js, json
+          from pyscript import document, fetch
+
+          fetch_text = await fetch("config.json").text()
+          if (fetch_text != js.fetch_text):
+            raise Exception("fetch_text")
+
+          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()
+          if (len(fetch_buffer) != js.fetch_buffer):
+            raise Exception("fetch_buffer")
+
+          print(await fetch("config.json").bytearray())
+          print(await fetch("config.json").blob())
+
+          try:
+            await fetch("shenanigans.nope").text()
+          except:
+            document.documentElement.classList.add('mpy')
+      </script>
+      <script type="py" async>
+        import js, json
+        from pyscript import document, fetch
+
+        fetch_text = await fetch("config.json").text()
+        if (fetch_text != js.fetch_text):
+          raise Exception("fetch_text")
+
+        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()
+        if (len(fetch_buffer) != js.fetch_buffer):
+          raise Exception("fetch_buffer")
+
+        print(await fetch("config.json").bytearray())
+        print(await fetch("config.json").blob())
+
+        try:
+          await fetch("shenanigans.nope").text()
+        except:
+          document.documentElement.classList.add('py')
+      </script>
+    </body>
+</html>
diff --git a/pyscript.core/test/mpy.spec.js b/pyscript.core/test/mpy.spec.js
--- a/pyscript.core/test/mpy.spec.js
+++ b/pyscript.core/test/mpy.spec.js
@@ -78,3 +78,8 @@ test('Pyodide + multiple terminals via Worker', async ({ page }) => {
   await page.goto('http://localhost:8080/test/py-terminals.html');
   await page.waitForSelector('html.first.second');
 });
+
+test('MicroPython + Pyodide fetch', async ({ page }) => {
+  await page.goto('http://localhost:8080/test/fetch.html');
+  await page.waitForSelector('html.mpy.py');
+});