Repository: pyodide/pyodide

Issue:
'Future' object has no attribute 'then'
## 🐛 Bug

If I understand correctly, starting with version 0.17, Pyodide integrates the implementation of `await` for JsProxy. So when JS returns a `Promise`, it converts it to `Future` in Python, which allows us to use `await`.

Hoverer, the `Future` object has no attribute `then`, so it is no longer possible to build `then`/`catch` chains like in older versions.

### To Reproduce

```python
import js
js.fetch('http://karay.me/truepyxel/test.json').then(lambda resp: resp.json()).then(lambda data: data.msg).catch(lambda err: 'there were error: '+err.message)
```

### Environment

- Pyodide Version: 0.20
- Browser version<!-- (e.g. Chrome 95.0.4638.54) -->: 103.0.5060.134


Discussion:
Yes we should add `then`, `catch`, and `finally_` methods to the `Future`.

Resolving patch:
diff --git a/src/py/pyodide/webloop.py b/src/py/pyodide/webloop.py
--- a/src/py/pyodide/webloop.py
+++ b/src/py/pyodide/webloop.py
@@ -1,8 +1,10 @@
 import asyncio
 import contextvars
+import inspect
 import sys
 import time
 import traceback
+from asyncio import Future
 from collections.abc import Callable
 from typing import Any
 
@@ -12,6 +14,108 @@
     from js import setTimeout
 
 
+class PyodideFuture(Future[Any]):
+    """A future with extra then, catch, and finally_ methods based on the
+    Javascript promise API.
+    """
+
+    def then(
+        self,
+        onfulfilled: Callable[[Any], Any] | None,
+        onrejected: Callable[[Exception], Any] | None = None,
+    ) -> "PyodideFuture":
+        """When the Future is done, either execute onfulfilled with the result
+        or execute onrejected with the exception.
+
+        Returns a new Future which will be marked done when either the
+        onfulfilled or onrejected callback is completed. If the return value of
+        the executed callback is awaitable it will be awaited repeatedly until a
+        nonawaitable value is received. The returned Future will be resolved
+        with that value. If an error is raised, the returned Future will be
+        rejected with the error.
+
+        Parameters
+        ----------
+        onfulfilled:
+            A function called if the Future is fulfilled. This function receives
+            one argument, the fulfillment value.
+
+        onrejected:
+            A function called if the Future is rejected. This function receives
+            one argument, the rejection value.
+
+        Returns
+        -------
+        A new future to be resolved when the original future is done and the
+        appropriate callback is also done.
+        """
+        result = PyodideFuture()
+
+        onfulfilled_: Callable[[Any], Any]
+        onrejected_: Callable[[Any], Any]
+        if onfulfilled:
+            onfulfilled_ = onfulfilled
+        else:
+
+            def onfulfilled_(x):
+                return x
+
+        if onrejected:
+            onrejected_ = onrejected
+        else:
+
+            def onrejected_(x):
+                raise x
+
+        async def callback(fut: Future[Any]) -> Any:
+            if fut.exception():
+                val = fut.exception()
+                fun = onrejected_
+            else:
+                fun = onfulfilled_
+                val = fut.result()
+            try:
+                r = fun(val)
+                while inspect.isawaitable(r):
+                    r = await r
+            except Exception as result_exception:
+                result.set_exception(result_exception)
+                return
+            result.set_result(r)
+
+        def wrapper(fut: Future[Any]) -> None:
+            asyncio.ensure_future(callback(fut))
+
+        self.add_done_callback(wrapper)
+        return result
+
+    def catch(self, onrejected: Callable[[Exception], Any] | None) -> "PyodideFuture":
+        return self.then(None, onrejected)
+
+    def finally_(self, onfinally: Callable[[], Any]) -> "PyodideFuture":
+        result = PyodideFuture()
+
+        async def callback(fut: Future[Any]) -> None:
+            exc = fut.exception()
+            try:
+                r = onfinally()
+                while inspect.isawaitable(r):
+                    r = await r
+            except Exception as e:
+                result.set_exception(e)
+                return
+            if exc:
+                result.set_exception(exc)
+            else:
+                result.set_result(fut.result())
+
+        def wrapper(fut: Future[Any]) -> None:
+            asyncio.ensure_future(callback(fut))
+
+        self.add_done_callback(wrapper)
+        return result
+
+
 class WebLoop(asyncio.AbstractEventLoop):
     """A custom event loop for use in Pyodide.
 
@@ -193,6 +297,10 @@ def run_in_executor(self, executor, func, *args):
             fut.set_exception(e)
         return fut
 
+    def create_future(self) -> asyncio.Future[Any]:
+        """Create a Future object attached to the loop."""
+        return PyodideFuture(loop=self)
+
     #
     # The remaining methods are copied directly from BaseEventLoop
     #
@@ -208,13 +316,6 @@ def time(self) -> float:
         """
         return time.monotonic()
 
-    def create_future(self) -> asyncio.Future[Any]:
-        """Create a Future object attached to the loop.
-
-        Copied from ``BaseEventLoop.create_future``
-        """
-        return asyncio.futures.Future(loop=self)
-
     def create_task(self, coro, *, name=None):
         """Schedule a coroutine object.
 


Test patch:
diff --git a/src/tests/test_webloop.py b/src/tests/test_webloop.py
--- a/src/tests/test_webloop.py
+++ b/src/tests/test_webloop.py
@@ -1,4 +1,5 @@
 import pytest
+from pytest_pyodide.decorator import run_in_pyodide
 
 
 def run_with_resolve(selenium, code):
@@ -229,3 +230,119 @@ async def test():
         )
     finally:
         selenium.run("loop.set_exception_handler(None)")
+
+
+@pytest.mark.asyncio
+async def test_pyodide_future():
+    import asyncio
+
+    from pyodide.webloop import PyodideFuture
+
+    fut = PyodideFuture()
+    increment = lambda x: x + 1
+    tostring = lambda x: repr(x)
+
+    def raises(x):
+        raise Exception(x)
+
+    rf = fut.then(increment).then(increment)
+    fut.set_result(5)
+    assert await rf == 7
+
+    e = Exception("oops")
+    fut = PyodideFuture()
+    rf = fut.then(increment, tostring)
+    fut.set_exception(e)
+    assert await rf == repr(e)
+
+    e = Exception("oops")
+    fut = PyodideFuture()
+    rf = fut.catch(tostring)
+    fut.set_exception(e)
+    assert await rf == repr(e)
+
+    async def f(x):
+        await asyncio.sleep(0.1)
+        return x + 1
+
+    fut = PyodideFuture()
+    rf = fut.then(f)
+
+    fut.set_result(6)
+    assert await rf == 7
+
+    fut = PyodideFuture()
+    rf = fut.then(raises)
+    fut.set_result(6)
+    try:
+        await rf
+    except Exception:
+        pass
+    assert repr(rf.exception()) == repr(Exception(6))
+
+    x = 0
+
+    def incx():
+        nonlocal x
+        x += 1
+
+    fut = PyodideFuture()
+    rf = fut.then(increment).then(increment).finally_(incx).finally_(incx)
+    assert x == 0
+    fut.set_result(5)
+    await rf
+    assert x == 2
+
+    fut = PyodideFuture()
+    rf = fut.then(increment).then(increment).finally_(incx).finally_(incx)
+    fut.set_exception(e)
+    try:
+        await rf
+    except Exception:
+        pass
+    assert x == 4
+
+    async def f1(x):
+        if x == 0:
+            return 7
+        await asyncio.sleep(0.1)
+        return f1(x - 1)
+
+    fut = PyodideFuture()
+    rf = fut.then(f1)
+    fut.set_result(3)
+    assert await rf == 7
+
+    async def f2():
+        await asyncio.sleep(0.1)
+        raise e
+
+    fut = PyodideFuture()
+    rf = fut.finally_(f2)
+    fut.set_result(3)
+    try:
+        await rf
+    except Exception:
+        pass
+    assert rf.exception() == e
+
+    fut = PyodideFuture()
+    rf = fut.finally_(f2)
+    fut.set_exception(Exception("oops!"))
+    try:
+        await rf
+    except Exception:
+        pass
+    assert rf.exception() == e
+
+
+@run_in_pyodide
+async def test_pyodide_future2(selenium):
+    from js import fetch
+
+    name = (
+        await fetch("https://pypi.org/pypi/pytest/json")
+        .then(lambda x: x.json())
+        .then(lambda x: x.info.name)
+    )
+    assert name == "pytest"