8000 Fix test_async and test_stdio_handling · hoodmane/pyscript@c4d8d44 · GitHub
[go: up one dir, main page]

Skip to content

Commit c4d8d44

Browse files
committed
Fix test_async and test_stdio_handling
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 `start_` is called, our custom loop just stores the tasks. When `start_` is called, all deferred tasks are scheduled and any later tasks are also scheduled immediately. This deferment should only apply to user logic, so we set the custom event loop in a context manager around the user calls and restore the original event loop when we're done.
1 parent 854e9d1 commit c4d8d44

File tree

5 files changed

+88
-9
lines changed

5 files changed

+88
-9
lines changed

pyscriptjs/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,7 @@ modules must contain a "plugin" attribute. For more information check the plugin
416416
this.incrementPendingTags();
417417
this.decrementPendingTags();
418418
await this.scriptTagsPromise;
419+
await this.interpreter._remote.pyscript_py._start_loop();
419420
}
420421

421422
// ================= registraton API ====================

pyscriptjs/src/python/pyscript/__init__.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import ast
22
import asyncio
33
import base64
4+
import contextvars
45
import html
56
import io
67
import re
78
import time
89
from collections import namedtuple
10+
from collections.abc import Callable
911
from contextlib import contextmanager
1012
from textwrap import dedent
13+
from typing import Any
1114

1215
import js
16+
from js import setTimeout
17+
from pyodide.ffi import create_once_callable
18+
from pyodide.webloop import WebLoop
1319

1420
try:
1521
from pyodide.code import eval_code
@@ -709,6 +715,85 @@ def deprecate(name, obj, instead):
709715
ns["PyScript"] = DeprecatedGlobal("PyScript", PyScript, message)
710716

711717

718+
class _PyscriptWebLoop(WebLoop):
719+
def __init__(self):
720+
super().__init__()
721+
self.ready = False
722+
self.deferred_handles = []
723+
724+
def call_later( # type: ignore[override]
725+
self,
726+
delay: float,
727+
callback: Callable[..., Any],
728+
*args: Any,
729+
context: contextvars.Context | None = None,
730+
) -> asyncio.Handle:
731+
"""Arrange for a callback to be called at a given time.
732+
Return a Handle: an opaque object with a cancel() method that
733+
can be used to cancel the call.
734+
The delay can be an int or float, expressed in seconds. It is
735+
always relative to the current time.
736+
Each callback will be called exactly once. If two callbacks
737+
are scheduled for exactly the same time, it undefined which
738+
will be called first.
739+
Any positional arguments after the callback will be passed to
740+
the callback when it is called.
741+
This uses `setTimeout(callback, delay)`
742+
"""
743+
if delay < 0:
744+
raise ValueError("Can't schedule in the past")
745+
h = asyncio.Handle(callback, args, self, context=context)
746+
747+
def run_handle():
748+
if h.cancelled():
749+
return
750+
try:
751+
h._run()
752+
except SystemExit as e:
753+
if self._system_exit_handler:
754+
self._system_exit_handler(e.code)
755+
else:
756+
raise
757+
except KeyboardInterrupt:
758+
if self._keyboard_interrupt_handler:
759+
self._keyboard_interrupt_handler()
760+
else:
761+
raise
762+
763+
if self.ready:
764+
setTimeout(create_once_callable(run_handle), delay * 1000)
765+
else:
766+
self.deferred_handles.append((run_handle, self.time() + delay))
767+
return h
768+
769+
def start_(self):
770+
t = self.time()
771+
for [run_handle, delay] in self.deferred_handles:
772+
delay = delay - t
773+
if delay < 0:
774+
delay = 0
775+
setTimeout(create_once_callable(run_handle), delay * 1000)
776+
self.ready = True
777+
self.deferred_handles = []
778+
779+
780+
_LOOP = _PyscriptWebLoop()
781+
782+
783+
@contextmanager
784+
def _pyscript_event_loop():
785+
orig_loop = asyncio.get_event_loop()
786+
asyncio.set_event_loop(_LOOP)
787+
try:
788+
yield
789+
finally:
790+
asyncio.set_event_loop(orig_loop)
791+
792+
793+
def _start_loop():
794+
_LOOP.start_()
795+
796+
712797
def _run_pyscript(code: str, id: str = None) -> JsProxy:
713798
"""Execute user code inside context managers.
714799
@@ -732,7 +817,7 @@ def _run_pyscript(code: str, id: str = None) -> JsProxy:
732817
"""
733818
import __main__
734819

735-
with _display_target(id):
820+
with _display_target(id), _pyscript_event_loop():
736821
result = eval_code(code, globals=__main__.__dict__)
737822

738823
return js.Object.new(result=result)

pyscriptjs/src/remote_interpreter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type PyScriptPyModule = ProxyMarked & {
3737
uses_top_level_await(code: string): boolean;
3838
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3939
_run_pyscript(code: string, display_target_id?: string): { result: any };
40+
_start_loop(): void;
4041
};
4142

4243
/*

pyscriptjs/tests/integration/test_async.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import pytest
2-
31
from .support import PyScriptTest
42

53

@@ -124,7 +122,6 @@ async def a_func():
124122
inner_text = self.page.inner_text("html")
125123
assert "A0\nA1\nB0\nB1" in inner_text
126124

127-
@pytest.mark.xfail(reason="fails after introducing synclink, fix me soon!")
128125
def test_async_display_untargeted(self):
129126
self.pyscript_run(
130127
"""
@@ -151,7 +148,6 @@ async def a_func():
151148
== "Implicit target not allowed here. Please use display(..., target=...)"
152149
)
153150

154-
@pytest.mark.xfail(reason="fails after introducing synclink, fix me soon!")
155151
def test_sync_and_async_order(self):
156152
"""
157153
The order of execution is defined as follows:

pyscriptjs/tests/integration/test_stdio_handling.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import pytest
2-
31
from .support import PyScriptTest
42

53

@@ -100,7 +98,6 @@ def test_targeted_stdio_linebreaks(self):
10098

10199
self.assert_no_banners()
102100

103-
@pytest.mark.xfail(reason="fails after introducing synclink, fix me soon!")
104101
def test_targeted_stdio_async(self):
105102
# Test the behavior of stdio capture in async contexts
106103
self.pyscript_run(
@@ -149,7 +146,6 @@ async def coro(value, delay):
149146

150147
self.assert_no_banners()
151148

152-
@pytest.mark.xfail(reason="fails after introducing synclink, fix me soon!")
153149
def test_targeted_stdio_interleaved(self):
154150
# Test that synchronous writes to stdout are placed correctly, even
155151
# While interleaved with scheduling coroutines in the same tag

0 commit comments

Comments
 (0)
0