import ast
import asyncio
import base64
import html
import io
import re
import time
from collections import namedtuple
from textwrap import dedent
import js
try:
from pyodide import create_proxy
except ImportError:
from pyodide.ffi import create_proxy
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''
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"",
}
# 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
__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
@staticmethod
def run_until_complete(f):
_ = loop.run_until_complete(f)
@staticmethod
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().write instead."""
)
)
def set_current_display_target(target_id):
get_current_display_target._id = target_id
def get_current_display_target():
return get_current_display_target._id
get_current_display_target._id = None
def display(*values, target=None, append=True):
default_target = get_current_display_target()
if default_target is None and target is None:
raise Exception(
"Implicit target not allowed here. Please use display(..., target=...)"
)
if target is not None:
for v in values:
Element(target).write(v, append=append)
else:
for v in values:
Element(default_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 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"""
"""
)
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
def init(self, app):
self.app = app
def register_custom_element(self, tag):
# 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
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""
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 {name} 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 pyscript.{name} 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 import {name} instead.")
# special case
deprecate(
"dedent", dedent, "Please use from textwrap import dedent 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"{name} 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 js.{name} instead.")
# PyScript is special, use a different message
message = (
"The PyScript object is deprecated. "
"Please use pyscript instead."
)
ns["PyScript"] = DeprecatedGlobal("PyScript", PyScript, message)