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 all 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
8 changes: 8 additions & 0 deletions docs/project/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@
raise a `KeyboardInterrupt` by writing to the interrupt buffer.
[#1148](https://github.com/iodide-project/pyodide/pull/1148) and
[#1173](https://github.com/iodide-project/pyodide/pull/1173)
- A `JsProxy` of a Javascript `Promise` or other awaitable object is now a
Python awaitable.
[#880](https://github.com/iodide-project/pyodide/pull/880)
- Added a Python event loop to support asyncio by scheduling coroutines to run
as jobs on the browser event loop. This event loop is available by default and
automatically enabled by any relevant asyncio API, so for instance
`asyncio.ensure_future` works without any configuration.
hoodmane marked this conversation as resolved.
Show resolved Hide resolved
[#1158](https://github.com/iodide-project/pyodide/pull/1158)
- Made PyProxy of an iterable Python object an iterable Js object: defined the
`[Symbol.iterator]` method, can be used like `for(let x of proxy)`.
Made a PyProxy of a Python iterator an iterator: `proxy.next()` is
Expand Down
1 change: 1 addition & 0 deletions docs/usage/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Backward compatibility of the API is not guaranteed at this point.
pyodide.console.InteractiveConsole
pyodide.console.repr_shorten
pyodide.console.displayhook
pyodide.webloop.WebLoop
```


Expand Down
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 .webloop import WebLoopPolicy
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(WebLoopPolicy())


__version__ = "0.16.1"

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


from typing import Awaitable, Callable


class WebLoop(asyncio.AbstractEventLoop):
"""A custom event loop for use in Pyodide.

Schedules tasks on the browser event loop. Does no lifecycle management and runs
forever.

``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):
self._task_factory = None
asyncio._set_running_loop(self)

def get_debug(self):
return False

#
# Lifecycle methods: We ignore all lifecycle management
#

def is_running(self) -> bool:
"""Returns ``True`` if the event loop is running.

Always returns ``True`` because WebLoop has no lifecycle management.
"""
return True

def is_closed(self) -> bool:
"""Returns ``True`` if the event loop was closed.

Always returns ``False`` because WebLoop has no lifecycle management.
"""
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.
"""
pass

def run_forever(self):
"""Run the event loop forever. Does nothing in this implementation.

We 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.
"""
pass

def run_until_complete(self, future: Awaitable):
"""Run until future is done.

If the argument is a coroutine, it is wrapped in a Task.

The native event loop `run_until_complete` blocks until evaluation of the
future is complete and then returns the result of the future.
Since we cannot block, we just ensure that the future is scheduled and
return the future. This makes this method a bit useless. Instead, use
`future.add_done_callback(do_something_with_result)` or:
```python
async def wrapper():
result = await future
do_something_with_result(result)
```
"""
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):
"""Arrange for a callback to be called as soon as possible.

Any positional arguments after the callback will be passed to
the callback when it is called.

This schedules the callback on the browser event loop using ``setTimeout(callback, 0)``.
"""
delay = 0
return self.call_later(delay, callback, *args, context=context)

def call_soon_threadsafe(
callback: Callable, *args, context: contextvars.Context = None
):
"""Like ``call_soon()``, but thread-safe.

We have no threads so everything is "thread safe", and we just use ``call_soon``.
"""
return self.call_soon(callback, *args, context=context)

def call_later(
self,
delay: float,
callback: Callable,
*args,
context: contextvars.Context = None
):
"""Arrange for a callback to be called at a given time.

Return a Handle: an opaque object with a cancel() method that
can be used to cancel the call.

The delay can be an int or float, expressed in seconds. It is
always relative to the current time.

Each callback will be called exactly once. If two callbacks
are scheduled for exactly the same time, it undefined which
will be called first.

Any positional arguments after the callback will be passed to
the callback when it is called.

This uses `setTimeout(callback, delay)`
"""
from js import setTimeout

if delay < 0:
raise ValueError("Can't schedule in the past")
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
):
"""Like ``call_later()``, but uses an absolute time.

Absolute time corresponds to the event loop's ``time()`` method.

This uses ``setTimeout(callback, when - cur_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``
"""
self._check_closed()
if self._task_factory is None:
task = tasks.Task(coro, loop=self, name=name)
if task._source_traceback:
# Added comment:
# this only happens if get_debug() returns True.
# In that case, remove create_task from _source_traceback.
del task._source_traceback[-1]
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 WebLoopPolicy(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:
return self._default_loop
return self.new_event_loop()

def new_event_loop(self):
"""Create a new event loop"""
self._default_loop = WebLoop()
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