Fix test_async and test_stdio_handling (#1319)

Resolves pyscript#1313 and pyscript#1314. On top of pyscript#1318.

The point of these tests is to define the execution order of Tasks
that are scheduled in <py-script> tags: first all the py-script tags
are executed and their related lifecycle events. Once all of this
is done, we schedule any enqueued tasks.

To delay the execution of these tasks, we use a custom event loop for
pyExec with this defer behavior. Until schedule_deferred_tasks is called,
we defer tasks started by user code. schedule_deferred_tasks starts all 
deferred user tasks and switches to immediately scheduling any further
user tasks.
This commit is contained in:
Hood Chatham
2023-03-30 14:38:51 -07:00
committed by GitHub
parent b61e8435d1
commit e9122bca9d
7 changed files with 84 additions and 19 deletions

View File

@@ -414,6 +414,7 @@ modules must contain a "plugin" attribute. For more information check the plugin
this.incrementPendingTags();
this.decrementPendingTags();
await this.scriptTagsPromise;
await this.interpreter._remote.pyscript_py._schedule_deferred_tasks();
}
// ================= registraton API ====================

View File

@@ -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 _install_deprecated_globals_2022_12_1(ns):
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)

View File

@@ -35,8 +35,9 @@ type PATHInterface = {
type PyScriptPyModule = ProxyMarked & {
_set_version_info(ver: string): void;
uses_top_level_await(code: string): boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_run_pyscript(code: string, display_target_id?: string): { result: any };
_install_pyscript_loop(): void;
_schedule_deferred_tasks(): void;
};
/*
@@ -128,6 +129,7 @@ export class RemoteInterpreter extends Object {
this.globals = Synclink.proxy(this.interface.globals as PyProxyDict);
logger.info('importing pyscript');
this.pyscript_py = Synclink.proxy(this.interface.pyimport('pyscript')) as PyProxy & typeof this.pyscript_py;
this.pyscript_py._install_pyscript_loop();
if (config.packages) {
logger.info('Found packages in configuration to install. Loading micropip...');

View File

@@ -192,16 +192,15 @@ class TestBasic(PyScriptTest):
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)

View File

@@ -1,5 +1,3 @@
import pytest
from .support import PyScriptTest
@@ -124,7 +122,6 @@ class TestAsync(PyScriptTest):
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 @@ class TestAsync(PyScriptTest):
== "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:

View File

@@ -1,5 +1,3 @@
import pytest
from .support import PyScriptTest
@@ -100,7 +98,6 @@ class TestOutputHandling(PyScriptTest):
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 @@ class TestOutputHandling(PyScriptTest):
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

View File

@@ -3,3 +3,4 @@ from unittest.mock import Mock
document = Mock()
console = Mock()
setTimeout = Mock()