Compare commits

...

11 Commits

Author SHA1 Message Date
Fabio Pliger
20e8c00f79 more cleaning 2023-08-25 18:07:10 -05:00
Fabio Pliger
b1aa4d345b remove attributes in Style that were mapping directly to JS API 2023-08-25 17:59:40 -05:00
Fabio Pliger
6f516ed4d1 remove other unused code 2023-08-25 17:58:08 -05:00
Fabio Pliger
04d117c12d clean up non used old code during experimentation 2023-08-25 17:54:07 -05:00
Fabio Pliger
579e7ab87a add initial pydom code, raw from experimentation 2023-08-25 17:51:45 -05:00
Fabio Pliger
10e497c753 Merge branch 'antocuni/tmp-next-stdlib' into fpliger/stdlib 2023-08-25 17:39:22 -05:00
Fabio Pliger
3b46609614 add the python folder to src, where we can collect all python resources 2023-08-25 11:19:26 -05:00
Antonio Cuni
320ca306bd introduce the _pyscript python package, and move the code for display inside. This is probably incomplete, see the XXX inside the comments 2023-08-25 18:12:01 +02:00
Fabio Pliger
e087deef09 account for new display.py location 2023-08-24 12:56:02 -05:00
Fabio Pliger
c3bac976c8 move display.py to a stdlib folder 2023-08-24 12:42:03 -05:00
Fabio Pliger
4c3e5fabb9 move display.py to a stdlib folder 2023-08-24 12:41:51 -05:00
7 changed files with 545 additions and 18 deletions

File diff suppressed because one or more lines are too long

View File

@@ -20,5 +20,6 @@ export default {
output: {
esModule: true,
file: "./core.js",
sourcemap: true,
},
};

View File

@@ -2,9 +2,6 @@ import "@ungap/with-resolvers";
import { $ } from "basic-devtools";
import { define, XWorker } from "polyscript";
// this is imported as string (via rollup)
import display from "./display.py";
// TODO: this is not strictly polyscript related but handy ... not sure
// we should factor this utility out a part but this works anyway.
import { queryTarget } from "../node_modules/polyscript/esm/script-handler.js";
@@ -71,6 +68,18 @@ const bootstrapNodeAndPlugins = (pyodide, element, callback, hook) => {
for (const fn of hooks[hook]) fn(pyodide, element);
};
// these are imported as string (via rollup)
import init_py from "./stdlib/_pyscript/__init__.py";
import display_py from "./stdlib/_pyscript/_display.py";
const writeStdlib = (pyodide, element) => {
console.log("writeStdlib!");
const FS = pyodide.interpreter.FS;
FS.mkdirTree("/home/pyodide/_pyscript");
FS.writeFile("_pyscript/__init__.py", init_py, { encoding: "utf8" });
FS.writeFile("_pyscript/_display.py", display_py, { encoding: "utf8" });
};
const registerModule = ({ XWorker: $XWorker, interpreter, io }) => {
// automatically use the pyscript stderr (when/if defined)
// this defaults to console.error
@@ -81,15 +90,20 @@ const registerModule = ({ XWorker: $XWorker, interpreter, io }) => {
}
// trap once the python `display` utility (borrowed from "classic PyScript")
// provide the regular Pyodide globals instead of those from xworker
const pyDisplay = interpreter.runPython(
[
"import js",
"document=js.document",
"window=js",
display,
"display",
].join("\n"),
);
// const pyDisplay = interpreter.runPython(
// [
// "import js",
// "document=js.document",
// "window=js",
// display,
// "display",
// ].join("\n"),
// );
const pyDisplay = interpreter.runPython(`
from _pyscript import display
display
`);
interpreter.registerJsModule("pyscript", {
PyWorker,
document,
@@ -139,6 +153,8 @@ export const hooks = {
codeAfterRunWorkerAsync: new Set(),
};
// XXX antocuni: I think this is broken, because now _display.py imports
// window and document directly from js
const workerPyScriptModule = [
"from pyodide_js import FS",
`FS.writeFile('./pyscript.py', ${JSON.stringify(
@@ -147,7 +163,7 @@ const workerPyScriptModule = [
"document=polyscript.xworker.window.document",
"window=polyscript.xworker.window",
"sync=polyscript.xworker.sync",
display,
display_py,
].join("\n"),
)})`,
].join("\n");
@@ -183,6 +199,9 @@ define("py", {
bootstrapNodeAndPlugins(pyodide, element, after, "onAfterRunAsync");
},
async onInterpreterReady(pyodide, element) {
console.log("onInterpreterReady");
writeStdlib(pyodide, element);
console.log("after writeStdlib");
registerModule(pyodide, element);
// allows plugins to do whatever they want with the element
// before regular stuff happens in here

View File

@@ -1,10 +1,14 @@
# ⚠️ WARNING - both `document` and `window` are added at runtime
# XXX antocuni: I think this is wrong: it works in the main thread but not in
# the worker, because the rest of the code expects window and document to be
# proxies (see workerPyScriptModule in core.js)
import base64
import html
import io
import re
from js import document, window
_MIME_METHODS = {
"__repr__": "text/plain",
@@ -102,7 +106,7 @@ def _format_mime(obj):
break
if output is None:
if not_available:
window.console.warn(
window.console.warn( # noqa: F821
f"Rendered object requested unavailable MIME renderers: {not_available}"
)
output = repr(output)
@@ -120,7 +124,7 @@ def _write(element, value, append=False):
return
if append:
out_element = document.createElement("div")
out_element = document.createElement("div") # noqa: F821
element.append(out_element)
else:
out_element = element.lastElementChild
@@ -128,13 +132,15 @@ def _write(element, value, append=False):
out_element = element
if mime_type in ("application/javascript", "text/html"):
script_element = document.createRange().createContextualFragment(html)
script_element = document.createRange().createContextualFragment( # noqa: F821
html
)
out_element.append(script_element)
else:
out_element.innerHTML = html
def display(*values, target=None, append=True):
element = document.getElementById(target)
element = document.getElementById(target) # noqa: F821
for v in values:
_write(element, v, append=append)

View File

@@ -0,0 +1,354 @@
import inspect
import sys
from functools import cached_property
from typing import Any
import js
# from js import document as js_document
from pyodide.ffi import JsProxy
from pyodide.ffi.wrappers import add_event_listener
from pyscript import display
alert = js.alert
class BaseElement:
def __init__(self, js_element):
self._element = js_element
self._parent = None
self.style = StyleProxy(self)
def __eq__(self, obj):
"""Check if the element is the same as the other element by comparing
the underlying JS element"""
return isinstance(obj, BaseElement) and obj._element == self._element
@property
def parent(self):
if self._parent:
return self._parent
if self._element.parentElement:
self._parent = self.__class__(self._element.parentElement)
return self._parent
@property
def __class(self):
return self.__class__ if self.__class__ != PyDom else Element
def create(self, type_, is_child=True, classes=None, html=None, label=None):
js_el = js.document.createElement(type_)
element = self.__class(js_el)
if classes:
for class_ in classes:
element.add_class(class_)
if html is not None:
element.html = html
if label is not None:
element.label = label
if is_child:
self.append(element)
return element
class Element(BaseElement):
def append(self, child):
# TODO: this is Pyodide specific for now!!!!!!
# if we get passed a JSProxy Element directly we just map it to the
# higher level Python element
if isinstance(child, JsProxy):
return self.append(self.from_js(child))
elif isinstance(child, Element):
self._element.appendChild(child._element)
return child
def from_js(self, js_element):
return self.__class__(js.tagName, parent=self)
# TODO: These 2 should be changed to basically do what PyDom.__getitem__ does
# but within the scope of the current element children
def query(self, selector):
"""The querySelector() method of the Element interface returns the first element
that is a descendant of the element on which it is invoked that matches the specified
group of selectors.
"""
return self.__class__(self._element.querySelector(selector))
def query_all(self, selector):
"""The querySelectorAll() method of the Element interface returns a static (not live)
NodeList representing a list of the document's elements that match the specified group
of selectors.
"""
for element in self._element.querySelectorAll(selector):
yield self.__class__(element)
# -------- Boilerplate Proxy for the Element API -------- #
@property
def html(self):
return self._element.innerHTML
@html.setter
def html(self, value):
self._element.innerHTML = value
@property
def content(self):
return self._element.innerHTML
@content.setter
def content(self, value):
display(value, target=self.id)
@property
def id(self):
return self._element.id
@id.setter
def id(self, value):
self._element.id = value
@property
def checked(self):
return self._element.checked
@checked.setter
def checked(self, value):
self._element.checked = value
@property
def value(self):
tag = self._element.tagName
if tag == "INPUT":
if self._element.type == "checkbox":
return self._element.checked
elif self._element.type == "number":
return float(self._element.value)
else:
return self._element.value
return self._element.innerHTML
@value.setter
def value(self, value):
# TODO: This needs a bit more thinking. SHould we set .innerHTML or .text for instance?
tag = self._element.tagName
# print(f"Writing ({tag} )---> {self._selector} ---> {value}")
if tag == "INPUT":
# print(f"Writing ({tag} | {self._element.type})---> {self._selector} ---> {value}")
if self._element.type == "checkbox":
self._element.checked = value
elif self._element.type == "number":
self._element.value = float(value)
else:
self._element.value = value
else:
self._element.innerHTML = value
def clear(self):
self.value = ""
def clone(self, new_id=None):
clone = Element(self._element.cloneNode(True))
clone.id = new_id
return clone
def remove_class(self, classname):
classList = self._element.classList
if isinstance(classname, list):
classList.remove(*classname)
else:
classList.remove(classname)
return self
def add_class(self, classname):
classList = self._element.classList
if isinstance(classname, list):
classList.add(*classname)
else:
self._element.classList.add(classname)
return self
@property
def classes(self):
classes = self._element.classList.values()
return [x for x in classes]
def show_me(self):
self._element.scrollIntoView()
def when(self, event, handler):
document.when(self, event)(handler)
class StyleProxy(dict):
def __init__(self, element: Element) -> None:
self._element = element
@cached_property
def _style(self):
return self._element._element.style
def __getitem__(self, key):
self._style[key]
def __setitem__(self, key, value):
self._style.setProperty(key, value)
def pop(self, key):
self._style.removeProperty(key)
def set(self, **kws):
for k, v in kws.items():
self._element._element.style.setProperty(k, v)
# CSS Properties
# Reference: https://github.com/microsoft/TypeScript/blob/main/src/lib/dom.generated.d.ts#L3799C1-L5005C2
@property
def visibility(self):
return self._element._element.style.visibility
@visibility.setter
def visibility(self, value):
self._element._element.style.visibility = value
@property
def background(self):
return self._element._element.style.background
@background.setter
def background(self, value):
self._element._element.style.background = value
@property
def color(self):
return self._element._element.style.color
@color.setter
def color(self, value):
self._element._element.style.color = value
class StyleCollection:
def __init__(self, collection: "ElementCollection") -> None:
self._collection = collection
def __get__(self, obj, objtype=None):
return obj._get_attribute("style")
def __getitem__(self, key):
return self._collection._get_attribute("style")[key]
def __setitem__(self, key, value):
for element in self._collection._elements:
element.style[key] = value
def pop(self, key):
for element in self._collection._elements:
element.style.pop(key)
class ElementCollection:
def __init__(self, elements: [Element]) -> None:
self._elements = elements
self.style = StyleCollection(self)
def __getitem__(self, key):
if isinstance(key, int):
return self._elements[key]
elif isinstance(key, slice):
return ElementCollection(self._elements[key])
# TODO: In this case what do we expect??
elements = self._element.querySelectorAll(key)
return ElementCollection([Element(el) for el in elements])
def _get_attribute(self, attr):
# As JQuery, when getting an attr, only return it for the first element
return getattr(self._elements[0], attr)
def _set_attribute(self, attr, value):
for el in self._elements:
setattr(el, attr, value)
@property
def html(self):
return self._get_attribute("html")
@html.setter
def html(self, value):
self._set_attribute("html", value)
@property
def children(self):
return self._elements
def __iter__(self):
yield from self._elements
def __repr__(self):
return f"{self.__class__.__name__} (length: {len(self._elements)}) {self._elements}"
class DomScope:
def __getattr__(self, __name: str) -> Any:
element = document[f"#{__name}"]
if element:
return element[0]
class PyDom(BaseElement):
def __init__(self):
super().__init__(js.document)
self.ids = DomScope()
def create(self, type_, parent=None, classes=None, html=None):
return super().create(type_, is_child=False)
def __getitem__(self, key):
if isinstance(key, int):
indices = range(*key.indices(len(self.list)))
return [self.list[i] for i in indices]
elements = self._element.querySelectorAll(key)
if not elements:
return None
return ElementCollection([Element(el) for el in elements])
@staticmethod
def when(element, event_type):
# TODO: Ideally, we should have that implemented in PyScript not patched here
# if isinstance(element, Element):
# element = [element]
def decorator(func):
# elements = js.document.querySelectorAll(selector)
sig = inspect.signature(func)
# Function doesn't receive events
if not sig.parameters:
def wrapper(*args, **kwargs):
func()
# for el in element:
add_event_listener(element._element, event_type, wrapper)
else:
# for el in element:
add_event_listener(element._element, event_type, func)
return func
return decorator
document = PyDom()
sys.modules[__name__] = document

View File

@@ -0,0 +1,146 @@
# ⚠️ WARNING - both `document` and `window` are added at runtime
# XXX antocuni: I think this is wrong: it works in the main thread but not in
# the worker, because the rest of the code expects window and document to be
# proxies (see workerPyScriptModule in core.js)
import base64
import html
import io
import re
from js import document, window
_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:
window.console.warn( # noqa: F821
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 _write(element, value, append=False):
html, mime_type = _format_mime(value)
if html == "\\n":
return
if append:
out_element = document.createElement("div") # noqa: F821
element.append(out_element)
else:
out_element = element.lastElementChild
if out_element is None:
out_element = element
if mime_type in ("application/javascript", "text/html"):
script_element = document.createRange().createContextualFragment( # noqa: F821
html
)
out_element.append(script_element)
else:
out_element.innerHTML = html
def display(*values, target=None, append=True):
element = document.getElementById(target) # noqa: F821
for v in values:
_write(element, v, append=append)