remove PyWidget and py-register-widget + refactor PyList as a Python Plugin (#1452)

* remove PyWidget and py-register-widget

* refactor py-list as Plugin

* add newline

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix eslint

* handle case if src not supplied

* move inside if

* - Remove src attribute for py-list
- Re-implement as a Python plugin
- Remove pylist.py from examples directory
- Remove PyListPlugin as one of the default ones

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* move PyItem and PyList classes to the plugin

* clean up PyListTemplate and PyItemTemplate from pyscript module

* fix linting

* use PyList instead of PyListTemplate instead

* fix example for todo-pylist

* re-enable and improve test

* move py-list plugin to examples

* fix py-list plugin link

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Madhur Tandon
2023-05-10 20:17:07 +05:30
committed by GitHub
parent d3bcd87cfa
commit b247864414
10 changed files with 198 additions and 280 deletions

170
examples/py_list.py Normal file
View File

@@ -0,0 +1,170 @@
import time
from datetime import datetime as dt
from textwrap import dedent
import js
from pyscript import Element, Plugin, create
plugin = Plugin("PyList")
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:
item_class = PyItemTemplate
def __init__(self, parent):
self.parent = parent
self._children = []
self._id = self.parent.id
self.main_style_classes = "py-li-element"
@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.main_style_classes:
for klass in self.main_style_classes.split(" "):
main_div.classList.add(klass)
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 PyItem(PyItemTemplate):
def on_click(self, evt=None):
self.data["done"] = not self.data["done"]
self.strike(self.data["done"])
self.select("input").element.checked = self.data["done"]
class PyList(PyListTemplate):
item_class = PyItem
def add(self, item):
if isinstance(item, str):
item = {"content": item, "done": False, "created_at": dt.now()}
super().add(item, labels=["content"], state_key="done")
@plugin.register_custom_element("py-list")
class PyListPlugin:
def __init__(self, element):
self.element = element
self.py_list = PyList(self.element)
def add(self, item):
self.py_list.add(item)
def connect(self):
self.py_list.connect()

View File

@@ -1,21 +0,0 @@
from datetime import datetime as dt
import pyscript
class PyItem(pyscript.PyItemTemplate):
def on_click(self, evt=None):
self.data["done"] = not self.data["done"]
self.strike(self.data["done"])
self.select("input").element.checked = self.data["done"]
class PyList(pyscript.PyListTemplate):
item_class = PyItem
def add(self, item):
if isinstance(item, str):
item = {"content": item, "done": False, "created_at": dt.now()}
super().add(item, labels=["content"], state_key="done")

View File

@@ -28,23 +28,19 @@
</nav>
<section class="pyscript">
<h1>To Do List</h1>
<py-tutor modules="utils.py;pylist.py">
<py-register-widget
src="./pylist.py"
name="py-list"
klass="PyList"
></py-register-widget>
<py-tutor modules="utils.py">
<py-config>
plugins = [
"https://pyscript.net/latest/plugins/python/py_tutor.py"
"https://pyscript.net/latest/plugins/python/py_tutor.py",
"./py_list.py"
]
[[fetch]]
files = ["./utils.py", "./pylist.py"]
files = ["./utils.py"]
</py-config>
<py-script>
from js import document
from datetime import datetime as dt
from pyodide.ffi.wrappers import add_event_listener
def add_task(*args, **kws):
@@ -54,7 +50,8 @@
# add a new task to the list and tell it to use the `content` key to show in the UI
# and to use the key `done` to sync the task status with a checkbox element in the UI
myList.add(task)
myList = Element("myList")
myList.element.pyElementInstance.add(task)
# clear the inputbox element used to create the new task
new_task_content.clear()

View File

@@ -29,7 +29,8 @@
<py-tutor modules="./utils.py;./todo.py">
<py-config>
plugins = [
"https://pyscript.net/latest/plugins/python/py_tutor.py"
"https://pyscript.net/latest/plugins/python/py_tutor.py",
"./py_list.py"
]
[[fetch]]
files = ["./utils.py", "./todo.py"]

View File

@@ -12,8 +12,6 @@ skip = "pyscriptjs/node_modules/*,*.js,*.json"
[tool.ruff]
builtins = [
"Element",
"PyItemTemplate",
"PyListTemplate",
"pyscript",
]
ignore = [

View File

@@ -1,14 +1,11 @@
import { InterpreterClient } from '../interpreter_client';
import type { PyScriptApp } from '../main';
import { make_PyRepl } from './pyrepl';
import { make_PyWidget } from './pywidget';
function createCustomElements(interpreter: InterpreterClient, app: PyScriptApp) {
const PyWidget = make_PyWidget(interpreter);
const PyRepl = make_PyRepl(interpreter, app);
customElements.define('py-repl', PyRepl);
customElements.define('py-register-widget', PyWidget);
}
export { createCustomElements };

View File

@@ -1,93 +0,0 @@
import type { PyProxy, PyProxyCallable } from 'pyodide';
import { getLogger } from '../logger';
import { robustFetch } from '../fetch';
import { InterpreterClient } from '../interpreter_client';
import type { Remote } from 'synclink';
const logger = getLogger('py-register-widget');
function createWidget(interpreter: InterpreterClient, name: string, code: string, klass: string) {
class CustomWidget extends HTMLElement {
wrapper: HTMLElement;
name: string = name;
klass: string = klass;
code: string = code;
proxy: Remote<PyProxy & { connect(): void }>;
proxyClass: Remote<PyProxyCallable>;
constructor() {
super();
this.wrapper = document.createElement('slot');
this.attachShadow({ mode: 'open' }).appendChild(this.wrapper);
}
async connectedCallback() {
await interpreter.runButDontRaise(this.code);
this.proxyClass = (await interpreter.globals.get(this.klass)) as Remote<PyProxyCallable>;
this.proxy = (await this.proxyClass(this)) as Remote<PyProxy & { connect(): void }>;
await this.proxy.connect();
await this.registerWidget();
}
async registerWidget() {
logger.info('new widget registered:', this.name);
await interpreter.globals.set(this.id, this.proxy);
}
}
customElements.define(name, CustomWidget);
}
export function make_PyWidget(interpreter: InterpreterClient) {
class PyWidget extends HTMLElement {
name: string;
klass: string;
outputElement: HTMLElement;
errorElement: HTMLElement;
wrapper: HTMLElement;
theme: string;
source: string;
code: string;
constructor() {
super();
this.wrapper = document.createElement('slot');
this.attachShadow({ mode: 'open' }).appendChild(this.wrapper);
this.addAttributes('src', 'name', 'klass');
}
addAttributes(...attrs: string[]) {
for (const each of attrs) {
const property = each === 'src' ? 'source' : each;
if (this.hasAttribute(each)) {
this[property] = this.getAttribute(each);
}
}
}
async connectedCallback() {
if (this.id === undefined) {
throw new ReferenceError(
`No id specified for component. Components must have an explicit id. Please use id="" to specify your component id.`,
);
}
const mainDiv = document.createElement('div');
mainDiv.id = this.id + '-main';
this.appendChild(mainDiv);
logger.debug('PyWidget: reading source', this.source);
this.code = await this.getSourceFromFile(this.source);
createWidget(interpreter, this.name, this.code, this.klass);
}
async getSourceFromFile(s: string): Promise<string> {
const response = await robustFetch(s);
return await response.text();
}
}
return PyWidget;
}

View File

@@ -6,9 +6,6 @@ from ._event_loop import run_until_complete
from ._html import (
HTML,
Element,
PyItemTemplate,
PyListTemplate,
PyWidgetTheme,
add_classes,
create,
display,
@@ -43,9 +40,6 @@ __all__ = [
"Element",
"add_classes",
"create",
"PyWidgetTheme",
"PyItemTemplate",
"PyListTemplate",
"run_until_complete",
"loop",
"Plugin",

View File

@@ -1,4 +1,3 @@
import time
from textwrap import dedent
import js
@@ -148,143 +147,3 @@ def create(what, id_=None, classes=""):
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

View File

@@ -360,15 +360,31 @@ class TestExamples(PyScriptTest):
self.assert_no_banners()
self.check_tutor_generated_code(modules_to_check=["./utils.py", "./todo.py"])
@pytest.mark.xfail(reason="fails after introducing synclink, fix me soon!")
def test_todo_pylist(self):
# XXX improve this test
self.goto("examples/todo-pylist.html")
self.wait_for_pyscript()
assert self.page.title() == "Todo App"
wait_for_render(self.page, "*", "<input.*?id=['\"]new-task-content['\"].*?>")
todo_input = self.page.locator("input")
submit_task_button = self.page.locator("button#new-task-btn")
todo_input.type("Fold laundry")
submit_task_button.click()
first_task = self.page.locator("div#myList-c-0")
assert "Fold laundry" in first_task.inner_text()
task_checkbox = first_task.locator("input")
# Confirm that the new task isn't checked
assert not task_checkbox.is_checked()
# Let's mark it as done now
task_checkbox.check()
# Basic check that the task has the line-through class
assert "line-through" in first_task.get_attribute("class")
self.assert_no_banners()
self.check_tutor_generated_code(modules_to_check=["utils.py", "pylist.py"])
self.check_tutor_generated_code(modules_to_check=["utils.py"])
@pytest.mark.xfail(reason="To be moved to collective and updated, see issue #686")
def test_toga_freedom(self):