Split pyscript into multiple files (#1338)

In the future this should help us leak fewer names into the pyscript
namespace.

Rather than assigning to the pyscript module from JavaScript, we
mount a separate private JS module with the extra names needed by
PyScript. I moved a bit more interpeter intialization into
remote_interpreter.

I added a deprecation warning for `pyscript.js`: the proper way to
access `js` is `import js`.

---------

Co-authored-by: Antonio Cuni <anto.cuni@gmail.com>
This commit is contained in:
Hood Chatham
2023-04-12 05:49:47 -07:00
committed by GitHub
parent fc5089ac59
commit c886f887ae
22 changed files with 876 additions and 931 deletions

View File

@@ -60,7 +60,7 @@ export class InterpreterClient extends Object {
* serializable.
*/
async run(code: string, id?: string): Promise<{ result: any }> {
return this._remote.pyscript_py._run_pyscript(code, id);
return this._remote.pyscript_internal.run_pyscript(code, id);
}
/**

View File

@@ -3,8 +3,7 @@ import './styles/pyscript_base.css';
import { loadConfigFromElement } from './pyconfig';
import type { AppConfig } from './pyconfig';
import { InterpreterClient } from './interpreter_client';
import { version } from './version';
import { PluginManager, define_custom_element, Plugin, PythonPlugin } from './plugin';
import { PluginManager, Plugin, PythonPlugin } from './plugin';
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
import { getLogger } from './logger';
import { showWarning, globalExport, createLock } from './utils';
@@ -261,24 +260,6 @@ export class PyScriptApp {
// XXX: maybe the following calls could be parallelized, instead of
// await()ing immediately. For now I'm using await to be 100%
// compatible with the old behavior.
// inject `define_custom_element` and showWarning it into the PyScript
// module scope
// eventually replace the setHandler calls with interpreter._remote.setHandler i.e. the ones mentioned below
// await interpreter._remote.setHandler('define_custom_element', Synclink.proxy(define_custom_element));
// await interpreter._remote.setHandler('showWarning', Synclink.proxy(showWarning));
interpreter._unwrapped_remote.setHandler('define_custom_element', define_custom_element);
interpreter._unwrapped_remote.setHandler('showWarning', showWarning);
await interpreter._remote.pyscript_py._set_version_info(version);
// import some carefully selected names into the global namespace
await interpreter.run(`
import js
import pyscript
from pyscript import Element, display, HTML
pyscript._install_deprecated_globals_2022_12_1(globals())
`);
await Promise.all([this.installPackages(), this.fetchPaths(interpreter)]);
//This may be unnecessary - only useful if plugins try to import files fetch'd in fetchPaths()
@@ -414,7 +395,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();
await this.interpreter._remote.pyscript_internal.schedule_deferred_tasks();
}
// ================= registraton API ====================
@@ -443,4 +424,4 @@ if (typeof jest === 'undefined') {
globalApp.readyPromise = globalApp.main();
}
export { version };
export { version } from './version';

View File

@@ -1,6 +1,7 @@
import html
from pyscript import Plugin, js
import js
from pyscript import Plugin
js.console.warn(
"WARNING: This plugin is still in a very experimental phase and will likely change"

View File

@@ -12,7 +12,7 @@ export async function pyExec(
outElem: HTMLElement,
): Promise<{ result: any }> {
ensureUniqueId(outElem);
if (await interpreter._remote.pyscript_py.uses_top_level_await(pysrc)) {
if (await interpreter._remote.pyscript_internal.uses_top_level_await(pysrc)) {
const err = new UserError(
ErrorCode.TOP_LEVEL_AWAIT,
'The use of top-level "await", "async for", and ' +

View File

@@ -1,808 +1,54 @@
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_once_callable, create_proxy
except ImportError:
from pyodide import JsProxy, create_once_callable, create_proxy, eval_code
loop = asyncio.get_event_loop()
MIME_METHODS = {
"__repr__": "text/plain",
"_repr_html_": "text/html",
"_repr_markdown_": "text/markdown",
"_repr_svg_": "image/svg+xml",
"_repr_png_": "image/png",
"_repr_pdf_": "application/pdf",
"_repr_jpeg_": "image/jpeg",
"_repr_latex": "text/latex",
"_repr_json_": "application/json",
"_repr_javascript_": "application/javascript",
"savefig": "image/png",
}
def render_image(mime, value, meta):
# If the image value is using bytes we should convert it to base64
# otherwise it will return raw bytes and the browser will not be able to
# render it.
if isinstance(value, bytes):
value = base64.b64encode(value).decode("utf-8")
# This is the pattern of base64 strings
base64_pattern = re.compile(
r"^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$"
)
# If value doesn't match the base64 pattern we should encode it to base64
if len(value) > 0 and not base64_pattern.match(value):
value = base64.b64encode(value.encode("utf-8")).decode("utf-8")
data = f"data:{mime};charset=utf-8;base64,{value}"
attrs = " ".join(['{k}="{v}"' for k, v in meta.items()])
return f'<img src="{data}" {attrs}></img>'
def identity(value, meta):
return value
MIME_RENDERERS = {
"text/plain": html.escape,
"text/html": identity,
"image/png": lambda value, meta: render_image("image/png", value, meta),
"image/jpeg": lambda value, meta: render_image("image/jpeg", value, meta),
"image/svg+xml": identity,
"application/json": identity,
"application/javascript": lambda value, meta: f"<script>{value}</script>",
}
from _pyscript_js import showWarning
from ._event_loop import LOOP as loop
from ._event_loop import run_until_complete
from ._html import (
HTML,
Element,
PyItemTemplate,
PyListTemplate,
PyWidgetTheme,
add_classes,
create,
display,
write,
)
from ._plugin import Plugin
# these are set by _set_version_info
__version__ = None
version_info = None
def _set_version_info(version_from_interpreter: str):
"""Sets the __version__ and version_info properties from provided JSON data
Args:
version_from_interpreter (str): A "dotted" representation of the version:
YYYY.MM.m(m).releaselevel
Year, Month, and Minor should be integers; releaselevel can be any string
"""
global __version__
global version_info
def __getattr__(attr):
if attr == "js":
global js
import js
from _pyscript_js import showWarning
__version__ = version_from_interpreter
version_parts = version_from_interpreter.split(".")
year = int(version_parts[0])
month = int(version_parts[1])
minor = int(version_parts[2])
if len(version_parts) > 3:
releaselevel = version_parts[3]
else:
releaselevel = ""
VersionInfo = namedtuple("version_info", ("year", "month", "minor", "releaselevel"))
version_info = VersionInfo(year, month, minor, releaselevel)
# we ALSO set PyScript.__version__ and version_info for backwards
# compatibility. Should be killed eventually.
PyScript.__version__ = __version__
PyScript.version_info = version_info
class HTML:
"""
Wrap a string so that display() can render it as plain HTML
"""
def __init__(self, html):
self._html = html
def _repr_html_(self):
return self._html
def eval_formatter(obj, print_method):
"""
Evaluates a formatter method.
"""
if print_method == "__repr__":
return repr(obj)
elif hasattr(obj, print_method):
if print_method == "savefig":
buf = io.BytesIO()
obj.savefig(buf, format="png")
buf.seek(0)
return base64.b64encode(buf.read()).decode("utf-8")
return getattr(obj, print_method)()
elif print_method == "_repr_mimebundle_":
return {}, {}
return None
def format_mime(obj):
"""
Formats object using _repr_x_ methods.
"""
if isinstance(obj, str):
return html.escape(obj), "text/plain"
mimebundle = eval_formatter(obj, "_repr_mimebundle_")
if isinstance(mimebundle, tuple):
format_dict, _ = mimebundle
else:
format_dict = mimebundle
output, not_available = None, []
for method, mime_type in reversed(MIME_METHODS.items()):
if mime_type in format_dict:
output = format_dict[mime_type]
else:
output = eval_formatter(obj, method)
if output is None:
continue
elif mime_type not in MIME_RENDERERS:
not_available.append(mime_type)
continue
break
if output is None:
if not_available:
js.console.warn(
f"Rendered object requested unavailable MIME renderers: {not_available}"
)
output = repr(output)
mime_type = "text/plain"
elif isinstance(output, tuple):
output, meta = output
else:
meta = {}
return MIME_RENDERERS[mime_type](output, meta), mime_type
def run_until_complete(f):
_ = loop.run_until_complete(f)
def write(element_id, value, append=False, exec_id=0):
"""Writes value to the element with id "element_id"""
Element(element_id).write(value=value, append=append)
js.console.warn(
dedent(
"""PyScript Deprecation Warning: PyScript.write is
marked as deprecated and will be removed sometime soon. Please, use
Element(<id>).write instead."""
# Deprecated after 2023.03.1
showWarning(
"<code>pyscript.js</code> is deprecated, please use <code>import js</code> instead.",
"html",
)
)
@contextmanager
def _display_target(target_id):
get_current_display_target._id = target_id
try:
yield
finally:
get_current_display_target._id = None
def get_current_display_target():
return get_current_display_target._id
get_current_display_target._id = None
def display(*values, target=None, append=True):
if target is None:
target = get_current_display_target()
if target is None:
raise Exception(
"Implicit target not allowed here. Please use display(..., target=...)"
)
for v in values:
Element(target).write(v, append=append)
class Element:
def __init__(self, element_id, element=None):
self._id = element_id
self._element = element
@property
def id(self):
return self._id
@property
def element(self):
"""Return the dom element"""
if not self._element:
self._element = js.document.querySelector(f"#{self._id}")
return self._element
@property
def value(self):
return self.element.value
@property
def innerHtml(self):
return self.element.innerHTML
def write(self, value, append=False):
html, mime_type = format_mime(value)
if html == "\n":
return
if append:
child = js.document.createElement("div")
self.element.appendChild(child)
if append and self.element.children:
out_element = self.element.children[-1]
else:
out_element = self.element
if mime_type in ("application/javascript", "text/html"):
script_element = js.document.createRange().createContextualFragment(html)
out_element.appendChild(script_element)
else:
out_element.innerHTML = html
def clear(self):
if hasattr(self.element, "value"):
self.element.value = ""
else:
self.write("", append=False)
def select(self, query, from_content=False):
el = self.element
if from_content:
el = el.content
_el = el.querySelector(query)
if _el:
return Element(_el.id, _el)
else:
js.console.warn(f"WARNING: can't find element matching query {query}")
def clone(self, new_id=None, to=None):
if new_id is None:
new_id = self.element.id
clone = self.element.cloneNode(True)
clone.id = new_id
if to:
to.element.appendChild(clone)
# Inject it into the DOM
to.element.after(clone)
else:
# Inject it into the DOM
self.element.after(clone)
return Element(clone.id, clone)
def remove_class(self, classname):
if isinstance(classname, list):
for cl in classname:
self.remove_class(cl)
else:
self.element.classList.remove(classname)
def add_class(self, classname):
if isinstance(classname, list):
for cl in classname:
self.element.classList.add(cl)
else:
self.element.classList.add(classname)
def add_classes(element, class_list):
for klass in class_list.split(" "):
element.classList.add(klass)
def create(what, id_=None, classes=""):
element = js.document.createElement(what)
if id_:
element.id = id_
add_classes(element, classes)
return Element(id_, element)
class PyWidgetTheme:
def __init__(self, main_style_classes):
self.main_style_classes = main_style_classes
def theme_it(self, widget):
for klass in self.main_style_classes.split(" "):
widget.classList.add(klass)
class PyItemTemplate(Element):
label_fields = None
def __init__(self, data, labels=None, state_key=None, parent=None):
self.data = data
self.register_parent(parent)
if not labels:
labels = list(self.data.keys())
self.labels = labels
self.state_key = state_key
super().__init__(self._id)
def register_parent(self, parent):
self._parent = parent
if parent:
self._id = f"{self._parent._id}-c-{len(self._parent._children)}"
self.data["id"] = self._id
else:
self._id = None
def create(self):
new_child = create("div", self._id, "py-li-element")
new_child._element.innerHTML = dedent(
f"""
<label id="{self._id}" for="flex items-center p-2 ">
<input class="mr-2" type="checkbox" class="task-check">
<p>{self.render_content()}</p>
</label>
"""
)
return new_child
def on_click(self, evt):
pass
def pre_append(self):
pass
def post_append(self):
self.element.click = self.on_click
self.element.onclick = self.on_click
self._post_append()
def _post_append(self):
pass
def strike(self, value, extra=None):
if value:
self.add_class("line-through")
else:
self.remove_class("line-through")
def render_content(self):
return " - ".join([self.data[f] for f in self.labels])
class PyListTemplate:
theme = PyWidgetTheme("py-li-element")
item_class = PyItemTemplate
def __init__(self, parent):
self.parent = parent
self._children = []
self._id = self.parent.id
@property
def children(self):
return self._children
@property
def data(self):
return [c.data for c in self._children]
def render_children(self):
binds = {}
for i, c in enumerate(self._children):
txt = c.element.innerHTML
rnd = str(time.time()).replace(".", "")[-5:]
new_id = f"{c.element.id}-{i}-{rnd}"
binds[new_id] = c.element.id
txt = txt.replace(">", f" id='{new_id}'>")
print(txt)
def foo(evt):
evtEl = evt.srcElement
srcEl = Element(binds[evtEl.id])
srcEl.element.onclick()
evtEl.classList = srcEl.element.classList
for new_id in binds:
Element(new_id).element.onclick = foo
def connect(self):
self.md = main_div = js.document.createElement("div")
main_div.id = self._id + "-list-tasks-container"
if self.theme:
self.theme.theme_it(main_div)
self.parent.appendChild(main_div)
def add(self, *args, **kws):
if not isinstance(args[0], self.item_class):
child = self.item_class(*args, **kws)
else:
child = args[0]
child.register_parent(self)
return self._add(child)
def _add(self, child_elem):
self.pre_child_append(child_elem)
child_elem.pre_append()
self._children.append(child_elem)
self.md.appendChild(child_elem.create().element)
child_elem.post_append()
self.child_appended(child_elem)
return child_elem
def pre_child_append(self, child):
pass
def child_appended(self, child):
"""Overwrite me to define logic"""
pass
class TopLevelAsyncFinder(ast.NodeVisitor):
def is_source_top_level_await(self, source):
self.async_found = False
node = ast.parse(source)
self.generic_visit(node)
return self.async_found
def visit_Await(self, node):
self.async_found = True
def visit_AsyncFor(self, node):
self.async_found = True
def visit_AsyncWith(self, node):
self.async_found = True
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
pass # Do not visit children of async function defs
def uses_top_level_await(source: str) -> bool:
return TopLevelAsyncFinder().is_source_top_level_await(source)
class Plugin:
def __init__(self, name=None):
if not name:
name = self.__class__.__name__
self.name = name
self._custom_elements = []
self.app = None
def init(self, app):
self.app = app
def configure(self, config):
pass
def afterSetup(self, interpreter):
pass
def afterStartup(self, interpreter):
pass
def beforePyScriptExec(self, interpreter, src, pyScriptTag):
pass
def afterPyScriptExec(self, interpreter, src, pyScriptTag, result):
pass
def beforePyReplExec(self, interpreter, src, outEl, pyReplTag):
pass
def afterPyReplExec(self, interpreter, src, outEl, pyReplTag, result):
pass
def onUserError(self, error):
pass
def register_custom_element(self, tag):
"""
Decorator to register a new custom element as part of a Plugin and associate
tag to it. Internally, it delegates the registration to the PyScript internal
[JS] plugin manager, who actually creates the JS custom element that can be
attached to the page and instantiate an instance of the class passing the custom
element to the plugin constructor.
Exammple:
>> plugin = Plugin("PyTutorial")
>> @plugin.register_custom_element("py-tutor")
>> class PyTutor:
>> def __init__(self, element):
>> self.element = element
"""
# TODO: Ideally would be better to use the logger.
js.console.info(f"Defining new custom element {tag}")
def wrapper(class_):
# TODO: this is very pyodide specific but will have to do
# until we have JS interface that works across interpreters
define_custom_element(tag, create_proxy(class_)) # noqa: F821
self._custom_elements.append(tag)
return create_proxy(wrapper)
class DeprecatedGlobal:
"""
Proxy for globals which are deprecated.
The intendend usage is as follows:
# in the global namespace
Element = pyscript.DeprecatedGlobal('Element', pyscript.Element, "...")
console = pyscript.DeprecatedGlobal('console', js.console, "...")
...
The proxy forwards __getattr__ and __call__ to the underlying object, and
emit a warning on the first usage.
This way users see a warning only if they actually access the top-level
name.
"""
def __init__(self, name, obj, message):
self.__name = name
self.__obj = obj
self.__message = message
self.__warning_already_shown = False
def __repr__(self):
return f"<DeprecatedGlobal({self.__name!r})>"
def _show_warning(self, message):
"""
NOTE: this is overridden by unit tests
"""
# this showWarning is implemented in js and injected into this
# namespace by main.ts
showWarning(message, "html") # noqa: F821
def _show_warning_maybe(self):
if self.__warning_already_shown:
return
self._show_warning(self.__message)
self.__warning_already_shown = True
def __getattr__(self, attr):
self._show_warning_maybe()
return getattr(self.__obj, attr)
def __call__(self, *args, **kwargs):
self._show_warning_maybe()
return self.__obj(*args, **kwargs)
def __iter__(self):
self._show_warning_maybe()
return iter(self.__obj)
def __getitem__(self, key):
self._show_warning_maybe()
return self.__obj[key]
def __setitem__(self, key, value):
self._show_warning_maybe()
self.__obj[key] = value
class PyScript:
"""
This class is deprecated since 2022.12.1.
All its old functionalities are available as module-level functions. This
class should be killed eventually.
"""
loop = loop
@staticmethod
def run_until_complete(f):
run_until_complete(f)
@staticmethod
def write(element_id, value, append=False, exec_id=0):
write(element_id, value, append, exec_id)
def _install_deprecated_globals_2022_12_1(ns):
"""
Install into the given namespace all the globals which have been
deprecated since the 2022.12.1 release. Eventually they should be killed.
"""
def deprecate(name, obj, instead):
message = f"Direct usage of <code>{name}</code> is deprecated. " + instead
ns[name] = DeprecatedGlobal(name, obj, message)
# function/classes defined in pyscript.py ===> pyscript.XXX
pyscript_names = [
"PyItemTemplate",
"PyListTemplate",
"PyWidgetTheme",
"add_classes",
"create",
"loop",
]
for name in pyscript_names:
deprecate(
name, globals()[name], f"Please use <code>pyscript.{name}</code> instead."
)
# stdlib modules ===> import XXX
stdlib_names = [
"asyncio",
"base64",
"io",
"sys",
"time",
"datetime",
"pyodide",
"micropip",
]
for name in stdlib_names:
obj = __import__(name)
deprecate(name, obj, f"Please use <code>import {name}</code> instead.")
# special case
deprecate(
"dedent", dedent, "Please use <code>from textwrap import dedent</code> instead."
)
# these are names that used to leak in the globals but they are just
# implementation details. People should not use them.
private_names = [
"eval_formatter",
"format_mime",
"identity",
"render_image",
"MIME_RENDERERS",
"MIME_METHODS",
]
for name in private_names:
obj = globals()[name]
message = (
f"<code>{name}</code> is deprecated. "
"This is a private implementation detail of pyscript. "
"You should not use it."
)
ns[name] = DeprecatedGlobal(name, obj, message)
# these names are available as js.XXX
for name in ["document", "console"]:
obj = getattr(js, name)
deprecate(name, obj, f"Please use <code>js.{name}</code> instead.")
# PyScript is special, use a different message
message = (
"The <code>PyScript</code> object is deprecated. "
"Please use <code>pyscript</code> 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.
Uses the __main__ global namespace.
The output is wrapped inside a JavaScript object since an object is not
thenable. This is so we do not accidentally `await` the result of the python
execution, even if it's awaitable (Future, Task, etc.)
Parameters
----------
code :
The code to run
id :
The id for the default display target (or None if no default display target).
Returns
-------
A Js Object of the form {result: the_result}
"""
import __main__
with _display_target(id), _defer_user_asyncio():
result = eval_code(code, globals=__main__.__dict__)
return js.Object.new(result=result)
return js
raise AttributeError(f"module 'pyscript' has no attribute '{attr}'")
__all__ = [
"HTML",
"write",
"display",
"Element",
"add_classes",
"create",
"PyWidgetTheme",
"PyItemTemplate",
"PyListTemplate",
"run_until_complete",
"loop",
"Plugin",
"__version__",
"version_info",
"showWarning",
]

View File

@@ -0,0 +1,61 @@
from _pyscript_js import showWarning
class DeprecatedGlobal:
"""
Proxy for globals which are deprecated.
The intendend usage is as follows:
# in the global namespace
Element = pyscript.DeprecatedGlobal('Element', pyscript.Element, "...")
console = pyscript.DeprecatedGlobal('console', js.console, "...")
...
The proxy forwards __getattr__ and __call__ to the underlying object, and
emit a warning on the first usage.
This way users see a warning only if they actually access the top-level
name.
"""
def __init__(self, name, obj, message):
self.__name = name
self.__obj = obj
self.__message = message
self.__warning_already_shown = False
def __repr__(self):
return f"<DeprecatedGlobal({self.__name!r})>"
def _show_warning(self, message):
"""
NOTE: this is overridden by unit tests
"""
showWarning(message, "html") # noqa: F821
def _show_warning_maybe(self):
if self.__warning_already_shown:
return
self._show_warning(self.__message)
self.__warning_already_shown = True
def __getattr__(self, attr):
self._show_warning_maybe()
return getattr(self.__obj, attr)
def __call__(self, *args, **kwargs):
self._show_warning_maybe()
return self.__obj(*args, **kwargs)
def __iter__(self):
self._show_warning_maybe()
return iter(self.__obj)
def __getitem__(self, key):
self._show_warning_maybe()
return self.__obj[key]
def __setitem__(self, key, value):
self._show_warning_maybe()
self.__obj[key] = value

View File

@@ -0,0 +1,81 @@
import asyncio
import contextvars
from collections.abc import Callable
from contextlib import contextmanager
from typing import Any
from js import setTimeout
from pyodide.ffi import create_once_callable
from pyodide.webloop import WebLoop
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 = []
LOOP = None
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_until_complete(f):
return LOOP.run_until_complete(f)

View File

@@ -0,0 +1,289 @@
import time
from textwrap import dedent
from js import console, document
from . import _internal
from ._mime import format_mime as _format_mime
class HTML:
"""
Wrap a string so that display() can render it as plain HTML
"""
def __init__(self, html):
self._html = html
def _repr_html_(self):
return self._html
def write(element_id, value, append=False, exec_id=0):
"""Writes value to the element with id "element_id"""
Element(element_id).write(value=value, append=append)
console.warn(
dedent(
"""PyScript Deprecation Warning: PyScript.write is
marked as deprecated and will be removed sometime soon. Please, use
Element(<id>).write instead."""
)
)
def display(*values, target=None, append=True):
if target is None:
target = _internal.DISPLAY_TARGET
if target is None:
raise Exception(
"Implicit target not allowed here. Please use display(..., target=...)"
)
for v in values:
Element(target).write(v, append=append)
class Element:
def __init__(self, element_id, element=None):
self._id = element_id
self._element = element
@property
def id(self):
return self._id
@property
def element(self):
"""Return the dom element"""
if not self._element:
self._element = document.querySelector(f"#{self._id}")
return self._element
@property
def value(self):
return self.element.value
@property
def innerHtml(self):
return self.element.innerHTML
def write(self, value, append=False):
html, mime_type = _format_mime(value)
if html == "\n":
return
if append:
child = document.createElement("div")
self.element.appendChild(child)
if append and self.element.children:
out_element = self.element.children[-1]
else:
out_element = self.element
if mime_type in ("application/javascript", "text/html"):
script_element = document.createRange().createContextualFragment(html)
out_element.appendChild(script_element)
else:
out_element.innerHTML = html
def clear(self):
if hasattr(self.element, "value"):
self.element.value = ""
else:
self.write("", append=False)
def select(self, query, from_content=False):
el = self.element
if from_content:
el = el.content
_el = el.querySelector(query)
if _el:
return Element(_el.id, _el)
else:
console.warn(f"WARNING: can't find element matching query {query}")
def clone(self, new_id=None, to=None):
if new_id is None:
new_id = self.element.id
clone = self.element.cloneNode(True)
clone.id = new_id
if to:
to.element.appendChild(clone)
# Inject it into the DOM
to.element.after(clone)
else:
# Inject it into the DOM
self.element.after(clone)
return Element(clone.id, clone)
def remove_class(self, classname):
if isinstance(classname, list):
for cl in classname:
self.remove_class(cl)
else:
self.element.classList.remove(classname)
def add_class(self, classname):
if isinstance(classname, list):
for cl in classname:
self.element.classList.add(cl)
else:
self.element.classList.add(classname)
def add_classes(element, class_list):
for klass in class_list.split(" "):
element.classList.add(klass)
def create(what, id_=None, classes=""):
element = document.createElement(what)
if id_:
element.id = id_
add_classes(element, classes)
return Element(id_, element)
class PyWidgetTheme:
def __init__(self, main_style_classes):
self.main_style_classes = main_style_classes
def theme_it(self, widget):
for klass in self.main_style_classes.split(" "):
widget.classList.add(klass)
class PyItemTemplate(Element):
label_fields = None
def __init__(self, data, labels=None, state_key=None, parent=None):
self.data = data
self.register_parent(parent)
if not labels:
labels = list(self.data.keys())
self.labels = labels
self.state_key = state_key
super().__init__(self._id)
def register_parent(self, parent):
self._parent = parent
if parent:
self._id = f"{self._parent._id}-c-{len(self._parent._children)}"
self.data["id"] = self._id
else:
self._id = None
def create(self):
new_child = create("div", self._id, "py-li-element")
new_child._element.innerHTML = dedent(
f"""
<label id="{self._id}" for="flex items-center p-2 ">
<input class="mr-2" type="checkbox" class="task-check">
<p>{self.render_content()}</p>
</label>
"""
)
return new_child
def on_click(self, evt):
pass
def pre_append(self):
pass
def post_append(self):
self.element.click = self.on_click
self.element.onclick = self.on_click
self._post_append()
def _post_append(self):
pass
def strike(self, value, extra=None):
if value:
self.add_class("line-through")
else:
self.remove_class("line-through")
def render_content(self):
return " - ".join([self.data[f] for f in self.labels])
class PyListTemplate:
theme = PyWidgetTheme("py-li-element")
item_class = PyItemTemplate
def __init__(self, parent):
self.parent = parent
self._children = []
self._id = self.parent.id
@property
def children(self):
return self._children
@property
def data(self):
return [c.data for c in self._children]
def render_children(self):
binds = {}
for i, c in enumerate(self._children):
txt = c.element.innerHTML
rnd = str(time.time()).replace(".", "")[-5:]
new_id = f"{c.element.id}-{i}-{rnd}"
binds[new_id] = c.element.id
txt = txt.replace(">", f" id='{new_id}'>")
print(txt)
def foo(evt):
evtEl = evt.srcElement
srcEl = Element(binds[evtEl.id])
srcEl.element.onclick()
evtEl.classList = srcEl.element.classList
for new_id in binds:
Element(new_id).element.onclick = foo
def connect(self):
self.md = main_div = document.createElement("div")
main_div.id = self._id + "-list-tasks-container"
if self.theme:
self.theme.theme_it(main_div)
self.parent.appendChild(main_div)
def add(self, *args, **kws):
if not isinstance(args[0], self.item_class):
child = self.item_class(*args, **kws)
else:
child = args[0]
child.register_parent(self)
return self._add(child)
def _add(self, child_elem):
self.pre_child_append(child_elem)
child_elem.pre_append()
self._children.append(child_elem)
self.md.appendChild(child_elem.create().element)
child_elem.post_append()
self.child_appended(child_elem)
return child_elem
def pre_child_append(self, child):
pass
def child_appended(self, child):
"""Overwrite me to define logic"""
pass

View File

@@ -0,0 +1,115 @@
import ast
from collections import namedtuple
from contextlib import contextmanager
from js import Object
from pyodide.code import eval_code
from pyodide.ffi import JsProxy
from ._event_loop import (
defer_user_asyncio,
install_pyscript_loop,
schedule_deferred_tasks,
)
VersionInfo = namedtuple("version_info", ("year", "month", "minor", "releaselevel"))
def set_version_info(version_from_interpreter: str):
from . import __dict__ as pyscript_dict
"""Sets the __version__ and version_info properties from provided JSON data
Args:
version_from_interpreter (str): A "dotted" representation of the version:
YYYY.MM.m(m).releaselevel
Year, Month, and Minor should be integers; releaselevel can be any string
"""
version_parts = version_from_interpreter.split(".")
year = int(version_parts[0])
month = int(version_parts[1])
minor = int(version_parts[2])
if len(version_parts) > 3:
releaselevel = version_parts[3]
else:
releaselevel = ""
version_info = VersionInfo(year, month, minor, releaselevel)
pyscript_dict["__version__"] = version_from_interpreter
pyscript_dict["version_info"] = version_info
class TopLevelAwaitFinder(ast.NodeVisitor):
def is_source_top_level_await(self, source):
self.async_found = False
node = ast.parse(source)
self.generic_visit(node)
return self.async_found
def visit_Await(self, node):
self.async_found = True
def visit_AsyncFor(self, node):
self.async_found = True
def visit_AsyncWith(self, node):
self.async_found = True
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
pass # Do not visit children of async function defs
def uses_top_level_await(source: str) -> bool:
return TopLevelAwaitFinder().is_source_top_level_await(source)
DISPLAY_TARGET = None
@contextmanager
def display_target(target_id):
global DISPLAY_TARGET
DISPLAY_TARGET = target_id
try:
yield
finally:
DISPLAY_TARGET = None
def run_pyscript(code: str, id: str = None) -> JsProxy:
"""Execute user code inside context managers.
Uses the __main__ global namespace.
The output is wrapped inside a JavaScript object since an object is not
thenable. This is so we do not accidentally `await` the result of the python
execution, even if it's awaitable (Future, Task, etc.)
Parameters
----------
code :
The code to run
id :
The id for the default display target (or None if no default display target).
Returns
-------
A Js Object of the form {result: the_result}
"""
import __main__
with display_target(id), defer_user_asyncio():
result = eval_code(code, globals=__main__.__dict__)
return Object.new(result=result)
__all__ = [
"set_version_info",
"uses_top_level_await",
"run_pyscript",
"install_pyscript_loop",
"schedule_deferred_tasks",
]

View File

@@ -0,0 +1,113 @@
import base64
import html
import io
import re
from js import console
MIME_METHODS = {
"__repr__": "text/plain",
"_repr_html_": "text/html",
"_repr_markdown_": "text/markdown",
"_repr_svg_": "image/svg+xml",
"_repr_png_": "image/png",
"_repr_pdf_": "application/pdf",
"_repr_jpeg_": "image/jpeg",
"_repr_latex": "text/latex",
"_repr_json_": "application/json",
"_repr_javascript_": "application/javascript",
"savefig": "image/png",
}
def render_image(mime, value, meta):
# If the image value is using bytes we should convert it to base64
# otherwise it will return raw bytes and the browser will not be able to
# render it.
if isinstance(value, bytes):
value = base64.b64encode(value).decode("utf-8")
# This is the pattern of base64 strings
base64_pattern = re.compile(
r"^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$"
)
# If value doesn't match the base64 pattern we should encode it to base64
if len(value) > 0 and not base64_pattern.match(value):
value = base64.b64encode(value.encode("utf-8")).decode("utf-8")
data = f"data:{mime};charset=utf-8;base64,{value}"
attrs = " ".join(['{k}="{v}"' for k, v in meta.items()])
return f'<img src="{data}" {attrs}></img>'
def identity(value, meta):
return value
MIME_RENDERERS = {
"text/plain": html.escape,
"text/html": identity,
"image/png": lambda value, meta: render_image("image/png", value, meta),
"image/jpeg": lambda value, meta: render_image("image/jpeg", value, meta),
"image/svg+xml": identity,
"application/json": identity,
"application/javascript": lambda value, meta: f"<script>{value}</script>",
}
def eval_formatter(obj, print_method):
"""
Evaluates a formatter method.
"""
if print_method == "__repr__":
return repr(obj)
elif hasattr(obj, print_method):
if print_method == "savefig":
buf = io.BytesIO()
obj.savefig(buf, format="png")
buf.seek(0)
return base64.b64encode(buf.read()).decode("utf-8")
return getattr(obj, print_method)()
elif print_method == "_repr_mimebundle_":
return {}, {}
return None
def format_mime(obj):
"""
Formats object using _repr_x_ methods.
"""
if isinstance(obj, str):
return html.escape(obj), "text/plain"
mimebundle = eval_formatter(obj, "_repr_mimebundle_")
if isinstance(mimebundle, tuple):
format_dict, _ = mimebundle
else:
format_dict = mimebundle
output, not_available = None, []
for method, mime_type in reversed(MIME_METHODS.items()):
if mime_type in format_dict:
output = format_dict[mime_type]
else:
output = eval_formatter(obj, method)
if output is None:
continue
elif mime_type not in MIME_RENDERERS:
not_available.append(mime_type)
continue
break
if output is None:
if not_available:
console.warn(
f"Rendered object requested unavailable MIME renderers: {not_available}"
)
output = repr(output)
mime_type = "text/plain"
elif isinstance(output, tuple):
output, meta = output
else:
meta = {}
return MIME_RENDERERS[mime_type](output, meta), mime_type

View File

@@ -0,0 +1,66 @@
from _pyscript_js import define_custom_element
from js import console
from pyodide.ffi import create_proxy
class Plugin:
def __init__(self, name=None):
if not name:
name = self.__class__.__name__
self.name = name
self._custom_elements = []
self.app = None
def init(self, app):
self.app = app
def configure(self, config):
pass
def afterSetup(self, interpreter):
pass
def afterStartup(self, interpreter):
pass
def beforePyScriptExec(self, interpreter, src, pyScriptTag):
pass
def afterPyScriptExec(self, interpreter, src, pyScriptTag, result):
pass
def beforePyReplExec(self, interpreter, src, outEl, pyReplTag):
pass
def afterPyReplExec(self, interpreter, src, outEl, pyReplTag, result):
pass
def onUserError(self, error):
pass
def register_custom_element(self, tag):
"""
Decorator to register a new custom element as part of a Plugin and associate
tag to it. Internally, it delegates the registration to the PyScript internal
[JS] plugin manager, who actually creates the JS custom element that can be
attached to the page and instantiate an instance of the class passing the custom
element to the plugin constructor.
Exammple:
>> plugin = Plugin("PyTutorial")
>> @plugin.register_custom_element("py-tutor")
>> class PyTutor:
>> def __init__(self, element):
>> self.element = element
"""
# TODO: Ideally would be better to use the logger.
console.info(f"Defining new custom element {tag}")
def wrapper(class_):
# TODO: this is very pyodide specific but will have to do
# until we have JS interface that works across interpreters
define_custom_element(tag, create_proxy(class_)) # noqa: F821
self._custom_elements.append(tag)
return create_proxy(wrapper)

View File

@@ -1,4 +1,5 @@
import type { AppConfig } from './pyconfig';
import { version } from './version';
import { getLogger } from './logger';
import { Stdio } from './stdio';
import { InstallError, ErrorCode } from './exceptions';
@@ -6,6 +7,8 @@ import { robustFetch } from './fetch';
import type { loadPyodide as loadPyodideDeclaration, PyodideInterface, PyProxy, PyProxyDict } from 'pyodide';
import type { ProxyMarked } from 'synclink';
import * as Synclink from 'synclink';
import { showWarning } from './utils';
import { define_custom_element } from './plugin';
import { python_package } from './python_package';
@@ -32,12 +35,13 @@ type PATHInterface = {
dirname(path: string): string;
} & ProxyMarked;
type PyScriptPyModule = ProxyMarked & {
_set_version_info(ver: string): void;
type PyScriptInternalModule = ProxyMarked & {
set_version_info(ver: string): void;
uses_top_level_await(code: string): boolean;
_run_pyscript(code: string, display_target_id?: string): { result: any };
_install_pyscript_loop(): void;
_schedule_deferred_tasks(): void;
run_pyscript(code: string, display_target_id?: string): { result: any };
install_pyscript_loop(): void;
start_loop(): void;
schedule_deferred_tasks(): void;
};
/*
@@ -61,7 +65,7 @@ export class RemoteInterpreter extends Object {
FS: FSInterface;
PATH: PATHInterface;
PATH_FS: PATHFSInterface;
pyscript_py: PyScriptPyModule;
pyscript_internal: PyScriptInternalModule;
globals: PyProxyDict & ProxyMarked;
// TODO: Remove this once `runtimes` is removed!
@@ -95,6 +99,9 @@ export class RemoteInterpreter extends Object {
* path.
*/
async loadInterpreter(config: AppConfig, stdio: Synclink.Remote<Stdio & ProxyMarked>): Promise<void> {
// TODO: move this to "main thread"!
const _pyscript_js_main = { define_custom_element, showWarning };
this.interface = Synclink.proxy(
await loadPyodide({
stdout: (msg: string) => {
@@ -115,6 +122,7 @@ export class RemoteInterpreter extends Object {
// TODO: Remove this once `runtimes` is removed!
this.interpreter = this.interface;
this.interface.registerJsModule('_pyscript_js', _pyscript_js_main);
// Write pyscript package into file system
for (const dir of python_package.dirs) {
@@ -128,13 +136,22 @@ 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();
this.pyscript_internal = Synclink.proxy(this.interface.pyimport('pyscript._internal')) as PyProxy &
typeof this.pyscript_internal;
this.pyscript_internal.set_version_info(version);
this.pyscript_internal.install_pyscript_loop();
if (config.packages) {
logger.info('Found packages in configuration to install. Loading micropip...');
await this.loadPackage('micropip');
}
// import some carefully selected names into the global namespace
this.interface.runPython(`
import js
import pyscript
from pyscript import Element, display, HTML
`);
logger.info('pyodide loaded and initialized');
}

View File

@@ -247,44 +247,15 @@ class TestBasic(PyScriptTest):
self.pyscript_run(
"""
<py-script>
import pyscript
pyscript.showWarning("hello")
pyscript.showWarning("world")
from _pyscript_js import showWarning
showWarning("hello")
showWarning("world")
</py-script>
"""
)
with pytest.raises(AssertionError, match="Found 2 alert banners"):
self.assert_no_banners()
def test_deprecated_globals(self):
self.pyscript_run(
"""
<py-script>
# trigger various warnings
create("div", classes="a b c")
assert sys.__name__ == 'sys'
dedent("")
format_mime("")
assert MIME_RENDERERS['text/html'] is not None
console.log("hello")
PyScript.loop
</py-script>
<div id="mydiv"></div>
"""
)
banner = self.page.locator(".py-warning")
messages = banner.all_inner_texts()
assert messages == [
"The PyScript object is deprecated. Please use pyscript instead.",
"Direct usage of console is deprecated. Please use js.console instead.",
"MIME_RENDERERS is deprecated. This is a private implementation detail of pyscript. You should not use it.", # noqa: E501
"format_mime is deprecated. This is a private implementation detail of pyscript. You should not use it.", # noqa: E501
"Direct usage of dedent is deprecated. Please use from textwrap import dedent instead.",
"Direct usage of sys is deprecated. Please use import sys instead.",
"Direct usage of create is deprecated. Please use pyscript.create instead.",
]
def test_getPySrc_returns_source_code(self):
self.pyscript_run(
"""

View File

@@ -330,8 +330,8 @@ class TestOutput(PyScriptTest):
"""
<py-script>
print('print from python')
console.log('print from js')
console.error('error from js');
js.console.log('print from js')
js.console.error('error from js');
</py-script>
"""
)
@@ -348,8 +348,8 @@ class TestOutput(PyScriptTest):
<py-script>
display('this goes to the DOM')
print('print from python')
console.log('print from js')
console.error('error from js');
js.console.log('print from js')
js.console.error('error from js');
</py-script>
"""
)

View File

@@ -1,27 +1,32 @@
import os
import tarfile
import tempfile
from pathlib import Path
import pytest
import requests
from .support import PyScriptTest
URL = "https://github.com/pyodide/pyodide/releases/download/0.20.0/pyodide-build-0.20.0.tar.bz2"
TAR_NAME = "pyodide-build-0.20.0.tar.bz2"
@pytest.fixture
def tar_location(request):
val = request.config.cache.get("pyodide-0.20-tar", None)
def pyodide_0_22_0_tar(request):
"""
Fixture which returns a local copy of pyodide. It uses pytest-cache to
avoid re-downloading it between runs.
"""
URL = "https://github.com/pyodide/pyodide/releases/download/0.22.0/pyodide-core-0.22.0.tar.bz2"
tar_name = Path(URL).name
val = request.config.cache.get(tar_name, None)
if val is None:
response = requests.get(URL, stream=True)
TMP_DIR = tempfile.mkdtemp()
TMP_TAR_LOCATION = os.path.join(TMP_DIR, TAR_NAME)
TMP_TAR_LOCATION = os.path.join(TMP_DIR, tar_name)
with open(TMP_TAR_LOCATION, "wb") as f:
f.write(response.raw.read())
val = TMP_TAR_LOCATION
request.config.cache.set("pyodide-0.20-tar", val)
request.config.cache.set(tar_name, val)
return val
@@ -68,24 +73,20 @@ class TestConfig(PyScriptTest):
# The default pyodide version is 0.22.1 as of writing
# this test which is newer than the one we are loading below
# (after downloading locally) -- which is 0.20.0
# (after downloading locally) -- which is 0.22.0
# The test checks if loading a different interpreter is possible
# and that too from a locally downloaded file without needing
# the use of explicit `indexURL` calculation.
def test_interpreter_config(self, tar_location):
unzip(
location=tar_location,
extract_to=self.tmpdir,
)
def test_interpreter_config(self, pyodide_0_22_0_tar):
unzip(pyodide_0_22_0_tar, extract_to=self.tmpdir)
self.pyscript_run(
"""
<py-config type="json">
{
"interpreters": [{
"src": "/pyodide/pyodide.js",
"name": "pyodide-0.20.0",
"name": "my-own-pyodide",
"lang": "python"
}]
}
@@ -100,23 +101,21 @@ class TestConfig(PyScriptTest):
""",
)
assert self.console.log.lines[-1] == "version 0.20.0"
assert self.console.log.lines[-1] == "version 0.22.0"
version = self.page.locator("py-script").inner_text()
assert version == "0.20.0"
def test_runtime_still_works_but_shows_deprecation_warning(self, tar_location):
unzip(
location=tar_location,
extract_to=self.tmpdir,
)
assert version == "0.22.0"
def test_runtime_still_works_but_shows_deprecation_warning(
self, pyodide_0_22_0_tar
):
unzip(pyodide_0_22_0_tar, extract_to=self.tmpdir)
self.pyscript_run(
"""
<py-config type="json">
{
"runtimes": [{
"src": "/pyodide/pyodide.js",
"name": "pyodide-0.20.0",
"name": "my-own-pyodide",
"lang": "python"
}]
}
@@ -131,9 +130,9 @@ class TestConfig(PyScriptTest):
""",
)
assert self.console.log.lines[-1] == "version 0.20.0"
assert self.console.log.lines[-1] == "version 0.22.0"
version = self.page.locator("py-script").inner_text()
assert version == "0.20.0"
assert version == "0.22.0"
deprecation_banner = self.page.wait_for_selector(".alert-banner")
expected_message = (

View File

@@ -0,0 +1,4 @@
from unittest.mock import Mock
showWarning = Mock()
define_custom_element = Mock()

View File

@@ -16,10 +16,10 @@ sys.path.append(str(python_plugins_source))
# patch pyscript module where needed
import pyscript # noqa: E402
import pyscript_plugins_tester as ppt # noqa: E402
from pyscript import _plugin # noqa: E402
pyscript.define_custom_element = ppt.define_custom_element
_plugin.define_custom_element = ppt.define_custom_element
@pytest.fixture()

View File

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

View File

@@ -1,6 +1,7 @@
import xml.dom
from xml.dom.minidom import Node # nosec
import js
import pyscript
@@ -114,6 +115,6 @@ class Document(Node):
self._el = impl.createDocument(None, "document", None)
pyscript.js.document = doc = Document()
pyscript.js.document.head = doc.createElement("head")
pyscript.js.document.body = doc.createElement("body")
js.document = doc = Document()
js.document.head = doc.createElement("head")
js.document.body = doc.createElement("body")

View File

@@ -3,51 +3,54 @@ import textwrap
from unittest.mock import Mock
import pyscript
from pyscript import HTML, Element, _html
from pyscript._deprecated_globals import DeprecatedGlobal
from pyscript._internal import set_version_info, uses_top_level_await
from pyscript._mime import format_mime
class TestElement:
def test_id_is_correct(self):
el = pyscript.Element("something")
el = Element("something")
assert el.id == "something"
def test_element(self, monkeypatch):
el = pyscript.Element("something")
js_mock = Mock()
js_mock.document = Mock()
el = Element("something")
document = Mock()
call_result = "some_result"
js_mock.document.querySelector = Mock(return_value=call_result)
monkeypatch.setattr(pyscript, "js", js_mock)
document.querySelector = Mock(return_value=call_result)
monkeypatch.setattr(_html, "document", document)
assert not el._element
real_element = el.element
assert real_element
assert js_mock.document.querySelector.call_count == 1
js_mock.document.querySelector.assert_called_with("#something")
assert document.querySelector.call_count == 1
document.querySelector.assert_called_with("#something")
assert real_element == call_result
def test_format_mime_str():
obj = "just a string"
out, mime = pyscript.format_mime(obj)
out, mime = format_mime(obj)
assert out == obj
assert mime == "text/plain"
def test_format_mime_str_escaping():
obj = "<p>hello</p>"
out, mime = pyscript.format_mime(obj)
out, mime = format_mime(obj)
assert out == "&lt;p&gt;hello&lt;/p&gt;"
assert mime == "text/plain"
def test_format_mime_repr_escaping():
out, mime = pyscript.format_mime(sys)
out, mime = format_mime(sys)
assert out == "&lt;module 'sys' (built-in)&gt;"
assert mime == "text/plain"
def test_format_mime_HTML():
obj = pyscript.HTML("<p>hello</p>")
out, mime = pyscript.format_mime(obj)
obj = HTML("<p>hello</p>")
out, mime = format_mime(obj)
assert out == "<p>hello</p>"
assert mime == "text/html"
@@ -55,7 +58,7 @@ def test_format_mime_HTML():
def test_uses_top_level_await():
# Basic Case
src = "x = 1"
assert pyscript.uses_top_level_await(src) is False
assert uses_top_level_await(src) is False
# Comments are not top-level await
src = textwrap.dedent(
@@ -64,7 +67,7 @@ def test_uses_top_level_await():
"""
)
assert pyscript.uses_top_level_await(src) is False
assert uses_top_level_await(src) is False
# Top-level-await cases
src = textwrap.dedent(
@@ -74,7 +77,7 @@ def test_uses_top_level_await():
await foo
"""
)
assert pyscript.uses_top_level_await(src) is True
assert uses_top_level_await(src) is True
src = textwrap.dedent(
"""
@@ -82,7 +85,7 @@ def test_uses_top_level_await():
pass
"""
)
assert pyscript.uses_top_level_await(src) is True
assert uses_top_level_await(src) is True
src = textwrap.dedent(
"""
@@ -90,7 +93,7 @@ def test_uses_top_level_await():
pass
"""
)
assert pyscript.uses_top_level_await(src) is True
assert uses_top_level_await(src) is True
# Acceptable await/async for/async with cases
src = textwrap.dedent(
@@ -99,7 +102,7 @@ def test_uses_top_level_await():
await foo()
"""
)
assert pyscript.uses_top_level_await(src) is False
assert uses_top_level_await(src) is False
src = textwrap.dedent(
"""
@@ -108,7 +111,7 @@ def test_uses_top_level_await():
pass
"""
)
assert pyscript.uses_top_level_await(src) is False
assert uses_top_level_await(src) is False
src = textwrap.dedent(
"""
@@ -117,21 +120,17 @@ def test_uses_top_level_await():
pass
"""
)
assert pyscript.uses_top_level_await(src) is False
assert uses_top_level_await(src) is False
def test_set_version_info():
version_string = "1234.56.78.ABCD"
pyscript._set_version_info(version_string)
set_version_info(version_string)
assert pyscript.__version__ == version_string
assert pyscript.version_info == (1234, 56, 78, "ABCD")
#
# for backwards compatibility, should be killed eventually
assert pyscript.PyScript.__version__ == pyscript.__version__
assert pyscript.PyScript.version_info == pyscript.version_info
class MyDeprecatedGlobal(pyscript.DeprecatedGlobal):
class MyDeprecatedGlobal(DeprecatedGlobal):
"""
A subclass of DeprecatedGlobal, for tests.

View File

@@ -1,9 +1,9 @@
import html
from unittest.mock import Mock
import js
import py_markdown
import py_tutor
import pyscript
import pyscript_plugins_tester as ppt
TUTOR_SOURCE = """
@@ -52,7 +52,7 @@ class TestPyTutor:
related prism assets have been added to the page head
"""
# GIVEN a previous call to py_tutor.plugin.append_script_to_page
head = pyscript.js.document.head
head = js.document.head
# EXPECT the head to contain a link element pointing to the prism.min.css
links = head.getElementsByTagName("link")
@@ -76,7 +76,7 @@ class TestPyTutor:
to the page body
"""
# GIVEN a previous call to py_tutor.plugin.append_script_to_page
body = pyscript.js.document.body
body = js.document.body
# EXPECT the body of the page to contain a script of type text/javascript
# and that contains the py_tutor.PAGE_SCRIPT script
@@ -108,7 +108,7 @@ class TestPyTutor:
console.info.assert_any_call("Creating new code section element.")
# EXPECT the page body to contain a section with the input source code
body = pyscript.js.document.body
body = js.document.body
sections = body.getElementsByTagName("section")
section = sections[0]
assert "code" in section.classList._classes

View File

@@ -16,7 +16,7 @@ describe('RemoteInterpreter', () => {
const { port1, port2 } = new Synclink.FakeMessageChannel() as unknown as MessageChannel;
beforeAll(async () => {
const SRC = '../pyscriptjs/node_modules/pyodide/pyodide.js';
const config: AppConfig = { interpreters: [{ src: SRC }] };
const config: AppConfig = { interpreters: [{ src: SRC }], packages: [] };
// Dynamic import of RemoteInterpreter sees our mocked Python package.
({ RemoteInterpreter } = await import('../../src/remote_interpreter'));
const remote_interpreter = new RemoteInterpreter(SRC);