Better test support for Python Plugins (#1108)

* add plugins testing utils module

* add plugins manager fixture and init plugins tests helper in conftest

* add _custom_elements attribute to pyscript.Plugin to allow plugins to track the CE they register

* add test for py_tutor

* remove unrelated code from prims js script

* ensure a Plugin always has the app attribute and improve tests

* add tests for py_tutor create_code_section

* implement PluginsManager reset and add teardown on plugins_manager fixture to clean it up after a test

* add test to check if plugin has been registered

* add docstrings to new tests

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

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

* add docstrings to plugins tester

* add changes from main

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

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

* lint

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

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

* add todo to add remaining PluginsManager lifecycle events

Co-authored-by: Fabio Pliger <fpliger@anaconda.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Fabio Pliger
2023-01-24 15:32:16 -06:00
committed by GitHub
parent 0de8cd9ab7
commit d55340a817
7 changed files with 305 additions and 6 deletions

View File

@@ -90,10 +90,6 @@ class PyTutor:
# Add the JS file
script = js.document.createElement("script")
script.type = "text/javascript"
try:
script.appendChild(js.document.createTextNode(PAGE_SCRIPT))
except BaseException:
script.text = PAGE_SCRIPT
script.src = "./assets/prism/prism.js"
js.document.head.appendChild(script)

View File

@@ -494,11 +494,27 @@ class Plugin:
name = self.__class__.__name__
self.name = name
self._custom_elements = []
self.app = None
def init(self, app):
self.app = app
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}")
@@ -507,6 +523,7 @@ class Plugin:
# 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

@@ -2,6 +2,8 @@
import pathlib
import sys
import pytest
# current working directory
base_path = pathlib.Path().absolute()
# add pyscript folder to path
@@ -11,3 +13,16 @@ sys.path.append(str(python_source))
# add Python plugins folder to path
python_plugins_source = base_path / "src" / "plugins" / "python"
sys.path.append(str(python_plugins_source))
# patch pyscript module where needed
import pyscript # noqa: E402
import pyscript_plugins_tester as ppt # noqa: E402
pyscript.define_custom_element = ppt.define_custom_element
@pytest.fixture()
def plugins_manager():
"""return a new instance of a Test version the PyScript application plugins manager"""
yield ppt.plugins_manager # PluginsManager()
ppt.plugins_manager.reset()

View File

@@ -1,4 +1,4 @@
"""Mock module that emulates some of the pyodide js module features for the sake of tests"""
from unittest.mock import Mock
create_proxy = Mock()
create_proxy = Mock(side_effect=lambda x: x)

View File

@@ -1,4 +1,4 @@
"""Mock module that emulates some of the pyodide js module features for the sake of tests"""
from unittest.mock import Mock
create_proxy = Mock()
create_proxy = Mock(side_effect=lambda x: x)

View File

@@ -0,0 +1,119 @@
import xml.dom
from xml.dom.minidom import Node # nosec
import pyscript
class classList:
"""Class that (lightly) emulates the behaviour of HTML Nodes ClassList"""
def __init__(self):
self._classes = []
def add(self, classname: str):
"""Add classname to the classList"""
self._classes.append(classname)
def remove(self, classname: str):
"""Remove classname from the classList"""
self._classes.remove(classname)
class PluginsManager:
"""
Emulator of PyScript PluginsManager that can be used to simulate plugins lifecycle events
TODO: Currently missing most of the lifecycle events in PluginsManager implementation. Need
to add more than just addPythonPlugin
"""
def __init__(self):
self.plugins = []
# mapping containing all the custom elements createed by plugins
self._custom_elements = {}
def addPythonPlugin(self, pluginInstance: pyscript.Plugin):
"""
Add a pluginInstance to the plugins managed by the PluginManager and calls
pluginInstance.init(self) to initialized the plugin with the manager
"""
pluginInstance.init(self)
self.plugins.append(pluginInstance)
def reset(self):
"""
Unregister all plugins and related custom elements.
"""
for plugin in self.plugins:
plugin.app = None
self.plugins = []
self._custom_elements = {}
class CustomElement:
def __init__(self, plugin_class: pyscript.Plugin):
self.pyPluginInstance = plugin_class(self)
self.attributes = {}
self.innerHTML = ""
def connectedCallback(self):
return self.pyPluginInstance.connect()
def getAttribute(self, attr: str):
return self.attributes.get(attr)
def define_custom_element(tag, plugin_class: pyscript.Plugin):
"""
Mock method to emulate the behaviour of the PyScript `define_custom_element`
that basically creates a new CustomElement passing plugin_class as Python
proxy object. For more info check out the logic of the original implementation at:
src/plugin.ts:define_custom_element
"""
ce = CustomElement(plugin_class)
plugins_manager._custom_elements[tag] = ce
plugins_manager = PluginsManager()
# Init pyscript testing mocks
impl = xml.dom.getDOMImplementation()
class Node:
"""
Represent an HTML Node.
This classes us an abstraction on top of xml.dom.minidom.Node
"""
def __init__(self, el: Node):
self._el = el
self.classList = classList()
# Automatic delegation is a simple and short boilerplate:
def __getattr__(self, attr: str):
return getattr(self._el, attr)
def createElement(self, *args, **kws):
newEl = self._el.createElement(*args, **kws)
return Node(newEl)
class Document(Node):
"""
Represent an HTML Document.
This classes us an abstraction on top of xml.dom.minidom.Document
"""
def __init__(self):
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")

View File

@@ -1,6 +1,32 @@
import html
from unittest.mock import Mock
import py_markdown
import py_tutor
import pyscript
import pyscript_plugins_tester as ppt
TUTOR_SOURCE = """
<py-config>
packages = [
"folium",
"pandas"
]
plugins = [
"../build/plugins/python/py_tutor.py"
]
</py-config>
<py-script>
import folium
import json
import pandas as pd
from pyodide.http import open_url
# the rest of the code goes one
</py-script>
"""
class TestPyMarkdown:
@@ -15,3 +41,129 @@ class TestPyMarkdown:
py_markdown.plugin.afterStartup(interpreter)
console_mock.log.assert_called_with("interpreter received: just an interpreter")
class TestPyTutor:
def check_prism_added(self):
"""
Assert that the add_prism method has been correctly executed and the
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
# EXPECT the head to contain a link element pointing to the prism.css
links = head.getElementsByTagName("link")
assert len(links) == 1
link = links[0]
assert link.type == "text/css"
assert link.rel == "stylesheet"
assert link.href == "./assets/prism/prism.css"
# EXPECT the head to contain a script src == prism.js
scripts = head.getElementsByTagName("script")
assert len(scripts) == 1
script = scripts[0]
assert script.type == "text/javascript"
assert script.src == "./assets/prism/prism.js"
def check_append_script_to_page(self):
"""
Assert that the append_script_to_page has been correctly executed and the
py_tutor.PAGE_SCRIPT code needed for the plugin JS animation has been added
to the page body
"""
# GIVEN a previous call to py_tutor.plugin.append_script_to_page
body = pyscript.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
scripts = body.getElementsByTagName("script")
assert len(scripts) == 1
script = scripts[0]
assert script.type == "text/javascript"
# Check the actual JS script code
# To do so we have 2 methods (it depends on browser support so we check either...)
if script.childNodes:
# in this case it means the content has been added as a child element
node = script.childNodes[0]
assert node.data == py_tutor.PAGE_SCRIPT
else:
assert script.text == py_tutor.PAGE_SCRIPT
def check_create_code_section(self):
"""
Assert that the create_code_section has been correctly executed and the
related code section has been created and added to the page.
"""
# GIVEN a previous call to py_tutor.plugin.check_create_code_section
console = py_tutor.js.console
# EXPECT the console to have the messages printed by the plugin while
# executing the plugin operations
console.info.assert_any_call("Creating code introspection section.")
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
sections = body.getElementsByTagName("section")
section = sections[0]
assert "code" in section.classList._classes
section_innerHTML = py_tutor.TEMPLATE_CODE_SECTION.format(
source=html.escape(TUTOR_SOURCE), modules_section=""
)
assert html.escape(TUTOR_SOURCE) in section.innerHTML
assert section.innerHTML == section_innerHTML
def test_connected_calls(self, plugins_manager: ppt.PluginsManager):
"""
Test that all parts of the plugin have been added to the page body and head
properly. This test effectively calls `self.check_prism_added`,
`self.check_append_script_to_page` and `check_create_code_section` assert
the new nodes have been added properly.
"""
# GIVEN THAT we add the plugin to the app plugin manager
# this will:
# - init the plugin instance passing the plugins_manager as parent app
# - add the plugin instance to plugins_manager.plugins
assert not py_tutor.plugin.app
plugins_manager.addPythonPlugin(py_tutor.plugin)
# EXPECT: the plugin app to now be the plugin manager
assert py_tutor.plugin.app == plugins_manager
tutor_ce = plugins_manager._custom_elements["py-tutor"]
# tutor_ce_python_instance = tutor_ce.pyPluginInstance
# GIVEN: The following innerHTML on the ce elements
tutor_ce.innerHTML = TUTOR_SOURCE
# GIVEN: the CustomElement connectedCallback gets called
tutor_ce.connectedCallback()
# EXPECT: the
self.check_prism_added()
self.check_append_script_to_page()
self.check_create_code_section()
def test_plugin_registered(self, plugins_manager: ppt.PluginsManager):
"""
Test that, when registered, plugin actually has an app attribute set
and that it's present in plugins manager plugins list.
"""
# EXPECT py_tutor.plugin to not have any app associate
assert not py_tutor.plugin.app
# EXPECT: the plugin manager to not have any plugin registered
assert not plugins_manager.plugins
# GIVEN THAT we add the plugin to the app plugin manager
plugins_manager.addPythonPlugin(py_tutor.plugin)
# EXPECT: the plugin app to now be the plugin manager
assert py_tutor.plugin.app == plugins_manager
assert "py-tutor" in py_tutor.plugin._custom_elements
# EXPECT: the pytutor.plugin manager to be part of
assert py_tutor.plugin in plugins_manager.plugins