Repository: pyscript/pyscript

Issue:
Fix the xfail tests in test_async.py
This is a follow-up of https://github.com/pyscript/pyscript/pull/1258.
Original discussion: https://github.com/pyscript/pyscript/pull/1258/files#r1149403259

These two tests were temporarily skipped to make it possible to merge the PR earlier, but they should now be fixed ASAP.


Discussion:

Resolving patch:
diff --git a/pyscriptjs/src/python/pyscript/__init__.py b/pyscriptjs/src/python/pyscript/__init__.py
--- a/pyscriptjs/src/python/pyscript/__init__.py
+++ b/pyscriptjs/src/python/pyscript/__init__.py
@@ -1,21 +1,26 @@
 import ast
 import asyncio
 import base64
+import contextvars
 import html
 import io
 import re
 import time
 from collections import namedtuple
+from collections.abc import Callable
 from contextlib import contextmanager
 from textwrap import dedent
+from typing import Any
 
 import js
+from js import setTimeout
+from pyodide.webloop import WebLoop
 
 try:
     from pyodide.code import eval_code
-    from pyodide.ffi import JsProxy, create_proxy
+    from pyodide.ffi import JsProxy, create_once_callable, create_proxy
 except ImportError:
-    from pyodide import JsProxy, create_proxy, eval_code
+    from pyodide import JsProxy, create_once_callable, create_proxy, eval_code
 
 
 loop = asyncio.get_event_loop()
@@ -709,6 +714,71 @@ def deprecate(name, obj, instead):
     ns["PyScript"] = DeprecatedGlobal("PyScript", PyScript, message)
 
 
+class _PyscriptWebLoop(WebLoop):
+    def __init__(self):
+        super().__init__()
+        self._ready = False
+        self._usercode = False
+        self._deferred_handles = []
+
+    def call_later(
+        self,
+        delay: float,
+        callback: Callable[..., Any],
+        *args: Any,
+        context: contextvars.Context | None = None,
+    ) -> asyncio.Handle:
+        """Based on call_later from Pyodide's webloop
+
+        With some unneeded stuff removed and a mechanism for deferring tasks
+        scheduled from user code.
+        """
+        if delay < 0:
+            raise ValueError("Can't schedule in the past")
+        h = asyncio.Handle(callback, args, self, context=context)
+
+        def run_handle():
+            if h.cancelled():
+                return
+            h._run()
+
+        if self._ready or not self._usercode:
+            setTimeout(create_once_callable(run_handle), delay * 1000)
+        else:
+            self._deferred_handles.append((run_handle, self.time() + delay))
+        return h
+
+    def _schedule_deferred_tasks(self):
+        asyncio._set_running_loop(self)
+        t = self.time()
+        for [run_handle, delay] in self._deferred_handles:
+            delay = delay - t
+            if delay < 0:
+                delay = 0
+            setTimeout(create_once_callable(run_handle), delay * 1000)
+        self._ready = True
+        self._deferred_handles = []
+
+
+def _install_pyscript_loop():
+    global _LOOP
+    _LOOP = _PyscriptWebLoop()
+    asyncio.set_event_loop(_LOOP)
+
+
+def _schedule_deferred_tasks():
+    _LOOP._schedule_deferred_tasks()
+
+
+@contextmanager
+def _defer_user_asyncio():
+    _LOOP._usercode = True
+    try:
+        yield
+    finally:
+        _LOOP._usercode = False
+
+
 def _run_pyscript(code: str, id: str = None) -> JsProxy:
     """Execute user code inside context managers.
 
@@ -732,7 +802,7 @@ def _run_pyscript(code: str, id: str = None) -> JsProxy:
     """
     import __main__
 
-    with _display_target(id):
+    with _display_target(id), _defer_user_asyncio():
         result = eval_code(code, globals=__main__.__dict__)
 
     return js.Object.new(result=result)


Test patch:
diff --git a/pyscriptjs/tests/integration/test_01_basic.py b/pyscriptjs/tests/integration/test_01_basic.py
--- a/pyscriptjs/tests/integration/test_01_basic.py
+++ b/pyscriptjs/tests/integration/test_01_basic.py
@@ -192,16 +192,15 @@ def test_py_script_src_attribute(self):
         assert self.console.log.lines[-1] == "hello from foo"
 
     def test_py_script_src_not_found(self):
-        self.pyscript_run(
-            """
-            <py-script src="foo.py"></py-script>
-            """
-        )
+        with pytest.raises(JsErrors) as exc:
+            self.pyscript_run(
+                """
+                <py-script src="foo.py"></py-script>
+                """
+            )
         assert self.PY_COMPLETE in self.console.log.lines
 
         assert "Failed to load resource" in self.console.error.lines[0]
-        with pytest.raises(JsErrors) as exc:
-            self.check_js_errors()
 
         error_msgs = str(exc.value)
 
diff --git a/pyscriptjs/tests/integration/test_async.py b/pyscriptjs/tests/integration/test_async.py
--- a/pyscriptjs/tests/integration/test_async.py
+++ b/pyscriptjs/tests/integration/test_async.py
@@ -1,5 +1,3 @@
-import pytest
-
 from .support import PyScriptTest
 
 
@@ -124,7 +122,6 @@ async def a_func():
         inner_text = self.page.inner_text("html")
         assert "A0\nA1\nB0\nB1" in inner_text
 
-    @pytest.mark.xfail(reason="fails after introducing synclink, fix me soon!")
     def test_async_display_untargeted(self):
         self.pyscript_run(
             """
@@ -151,7 +148,6 @@ async def a_func():
             == "Implicit target not allowed here. Please use display(..., target=...)"
         )
 
-    @pytest.mark.xfail(reason="fails after introducing synclink, fix me soon!")
     def test_sync_and_async_order(self):
         """
         The order of execution is defined as follows:
diff --git a/pyscriptjs/tests/integration/test_stdio_handling.py b/pyscriptjs/tests/integration/test_stdio_handling.py
--- a/pyscriptjs/tests/integration/test_stdio_handling.py
+++ b/pyscriptjs/tests/integration/test_stdio_handling.py
@@ -1,5 +1,3 @@
-import pytest
-
 from .support import PyScriptTest
 
 
@@ -100,7 +98,6 @@ def test_targeted_stdio_linebreaks(self):
 
         self.assert_no_banners()
 
-    @pytest.mark.xfail(reason="fails after introducing synclink, fix me soon!")
     def test_targeted_stdio_async(self):
         # Test the behavior of stdio capture in async contexts
         self.pyscript_run(
@@ -149,7 +146,6 @@ async def coro(value, delay):
 
         self.assert_no_banners()
 
-    @pytest.mark.xfail(reason="fails after introducing synclink, fix me soon!")
     def test_targeted_stdio_interleaved(self):
         # Test that synchronous writes to stdout are placed correctly, even
         # While interleaved with scheduling coroutines in the same tag
diff --git a/pyscriptjs/tests/py-unit/js.py b/pyscriptjs/tests/py-unit/js.py
--- a/pyscriptjs/tests/py-unit/js.py
+++ b/pyscriptjs/tests/py-unit/js.py
@@ -3,3 +3,4 @@
 
 document = Mock()
 console = Mock()
+setTimeout = Mock()