Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add simple web loop #1158

Merged
merged 24 commits into from
Feb 5, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/pyodide-py/pyodide/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
from ._base import open_url, eval_code, find_imports, as_nested_list
from ._core import JsException # type: ignore
from ._importhooks import JsFinder
from .simple_web_loop import SimpleWebLoopPolicy
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
import asyncio
import sys
import platform

jsfinder = JsFinder()
register_js_module = jsfinder.register_js_module
unregister_js_module = jsfinder.unregister_js_module
sys.meta_path.append(jsfinder) # type: ignore

if platform.system() == "Emscripten":
asyncio.set_event_loop_policy(SimpleWebLoopPolicy())


__version__ = "0.16.1"

Expand Down
203 changes: 203 additions & 0 deletions src/pyodide-py/pyodide/simple_web_loop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import asyncio
from asyncio import tasks, futures
import time
import contextvars


from typing import Awaitable, Callable


class SimpleWebLoop(asyncio.AbstractEventLoop):
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
"""A custom event loop for use in Pyodide.

Does no lifecycle management and runs forever (it is just deferring work to the
browser event loop which has no lifecycle so why should we have one?)
hoodmane marked this conversation as resolved.
Show resolved Hide resolved

run_forever and run_until_complete cannot block like a normal event loop would
because we only have one thread so blocking would stall the browser event loop
and prevent anything from ever happening.

We defer all work to the browser event loop using the setTimeout function.
To ensure that this event loop doesn't stall out UI and other browser handling,
we want to make sure that each task is scheduled on the browser event loop as a
task not as a microtask. `setTimeout(callback, 0)` enqueues the callback as a
task so it works well for our purposes.
"""

def __init__(self, debug: bool = False, interval: int = 10):
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
self._task_factory = None
asyncio._set_running_loop(self)

def get_debug(self):
return False

#
# Lifecycle methods: We just ignore all lifecycle management
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
#

def is_running(self) -> bool:
return True

def is_closed(self) -> bool:
return False

def _check_closed(self):
""" Used in create_task. Would raise an error if self.is_closed(), but we are skipping all lifecycle stuff. """
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
pass

def run_forever(self):
"""We cannot block like a normal event loop would
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
because we only have one thread so blocking would stall the browser event loop
and prevent anything from ever happening.
"""
pass

def run_until_complete(self, future: Awaitable):
""" Since we cannot block, we just ensure that the future is scheduled. """
return asyncio.ensure_future(future)

#
# Scheduling methods: use browser.setTimeout to schedule tasks on the browser event loop.
#

def call_soon(self, callback: Callable, *args, context: contextvars.Context = None):
"""
Schedule the callback callback to be called with args arguments at the next iteration of the event loop.
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
"""
delay = 0
return self.call_later(delay, callback, *args, context=context)

def call_soon_threadsafe(
callback: Callable, *args, context: contextvars.Context = None
):
"""
A thread-safe variant of call_soon().

Note this function is different from the standard asyncio loop implementation, it is current exactly the same as call_soon
"""
return self.call_soon(callback, *args, context=context)

def call_later(
self,
delay: float,
callback: Callable,
*args,
context: contextvars.Context = None
):
"""
Schedule callback to be called after the given delay number of seconds (can be either an int or a float).
"""
from js import setTimeout

if delay < 0:
raise Exception("Can't schedule in the past")
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
h = asyncio.Handle(callback, args, self, context=context)
setTimeout(h._run, delay * 1000)
return h

def call_at(
self,
when: float,
callback: Callable,
*args,
context: contextvars.Context = None
):
"""
Schedule callback to be called at the given absolute timestamp when (an int or a float), using the same time reference as loop.time().
"""
cur_time = self.time()
delay = when - cur_time
return self.call_later(delay, callback, *args, context=context)

#
# The remaining methods are copied directly from BaseEventLoop
#

def time(self):
"""Return the time according to the event loop's clock.

This is a float expressed in seconds since an epoch, but the
epoch, precision, accuracy and drift are unspecified and may
differ per event loop.

Copied from BaseEventLoop.time
"""
return time.monotonic()

def create_future(self):
"""Create a Future object attached to the loop.

Copied from BaseEventLoop.create_future
"""
return futures.Future(loop=self)

def create_task(self, coro, *, name=None):
"""Schedule a coroutine object.

Return a task object.

Copied from BaseEventLoop.create_task
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
"""
self._check_closed()
if self._task_factory is None:
task = tasks.Task(coro, loop=self, name=name)
if task._source_traceback:
del task._source_traceback[-1]
rth marked this conversation as resolved.
Show resolved Hide resolved
else:
task = self._task_factory(self, coro)
tasks._set_task_name(task, name)

return task

def set_task_factory(self, factory):
"""Set a task factory that will be used by loop.create_task().

If factory is None the default task factory will be set.

If factory is a callable, it should have a signature matching
'(loop, coro)', where 'loop' will be a reference to the active
event loop, 'coro' will be a coroutine object. The callable
must return a Future.

Copied from BaseEventLoop.set_task_factory
"""
if factory is not None and not callable(factory):
raise TypeError("task factory must be a callable or None")
self._task_factory = factory

def get_task_factory(self):
"""Return a task factory, or None if the default one is in use.

Copied from BaseEventLoop.get_task_factory
"""
return self._task_factory


class SimpleWebLoopPolicy(asyncio.DefaultEventLoopPolicy):
"""
A simple event loop policy for managing WebLoop based event loops.
"""

def __init__(self):
self._default_loop = None

def get_event_loop(self):
"""
Get the current event loop
"""
if self._default_loop is None:
self._default_loop = SimpleWebLoop()
return self._default_loop

def new_event_loop(self):
"""
Create a new event loop
"""
self._default_loop = SimpleWebLoop()
return self._default_loop

def set_event_loop(self, loop: asyncio.AbstractEventLoop):
"""
Set the current event loop
"""
self._default_loop = loop
3 changes: 2 additions & 1 deletion src/pyodide.js
Original file line number Diff line number Diff line change
Expand Up @@ -491,8 +491,9 @@ globalThis.languagePluginLoader = new Promise((resolve, reject) => {
let response = await fetch(`${baseURL}packages.json`);
let json = await response.json();
fixRecursionLimit(self.pyodide);
self.pyodide.registerJsModule("js", globalThis);
self.pyodide = makePublicAPI(self.pyodide, PUBLIC_API);
self.pyodide.registerJsModule("js", globalThis);
self.pyodide.registerJsModule("pyodide_js", self.pyodide);
self.pyodide._module.packages = json;
resolve();
};
Expand Down
147 changes: 147 additions & 0 deletions src/tests/test_webloop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
def run_with_resolve(selenium, code):
selenium.run_js(
f"""
try {{
let promise = new Promise((resolve) => window.resolve = resolve);
pyodide.runPython({code!r});
await promise;
}} finally {{
delete window.resolve;
}}
"""
)


def test_asyncio_sleep(selenium):
# test asyncio.sleep
run_with_resolve(
selenium,
"""
import asyncio
from js import resolve
async def sleep_task():
print('start sleeping for 1s')
await asyncio.sleep(1)
print('sleeping done')
resolve()
asyncio.ensure_future(sleep_task())
""",
)


def test_return_result(selenium):
# test return result
run_with_resolve(
selenium,
"""
from js import resolve
async def foo(arg):
return arg

def check_result(fut):
result = fut.result()
if result == 998:
resolve()
else:
raise Exception(f"Unexpected result {result!r}")
import asyncio
fut = asyncio.ensure_future(foo(998))
fut.add_done_callback(check_result)
""",
)


def test_capture_exception(selenium):
run_with_resolve(
selenium,
"""
from js import resolve
class MyException(Exception):
pass
async def foo(arg):
raise MyException('oops')

def capture_exception(fut):
try:
fut.result()
except MyException:
resolve()
else:
raise Exception("Expected fut.result() to raise MyException")
import asyncio
fut = asyncio.ensure_future(foo(998))
fut.add_done_callback(capture_exception)
""",
)


def test_await_js_promise(selenium):
run_with_resolve(
selenium,
"""
from js import fetch, resolve
async def fetch_task():
print('fetching data...')
result = await fetch('console.html')
resolve()
import asyncio
asyncio.ensure_future(fetch_task())
""",
)


def test_call_soon(selenium):
run_with_resolve(
selenium,
"""
from js import resolve
def foo(arg):
if arg == 'bar':
resolve()
else:
raise Exception("Expected arg == 'bar'...")
import asyncio
asyncio.get_event_loop().call_soon(foo, 'bar')
""",
)


def test_contextvars(selenium):
run_with_resolve(
selenium,
"""
from js import resolve
import contextvars
request_id = contextvars.ContextVar('Id of request.')
request_id.set(123)
ctx = contextvars.copy_context()
request_id.set(456)
def func_ctx():
if request_id.get() == 123:
resolve()
else:
raise Exception(f"Expected request_id.get() == '123', got {request_id.get()!r}")
import asyncio
asyncio.get_event_loop().call_soon(func_ctx, context=ctx)
""",
)


def test_asyncio_exception(selenium):
run_with_resolve(
selenium,
"""
from js import resolve
async def dummy_task():
raise ValueError("oops!")
async def capture_exception():
try:
await dummy_task()
except ValueError:
resolve()
else:
raise Exception("Expected ValueError")
import asyncio
asyncio.ensure_future(capture_exception())
""",
)