Skip to content

Commit

Permalink
feat(TrameComponent): provide helper class to handle method decoration
Browse files Browse the repository at this point in the history
  • Loading branch information
jourdain committed Feb 26, 2025
1 parent 533fbfd commit 53390eb
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 24 deletions.
1 change: 1 addition & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pytest
pytest-asyncio
seleniumbase
pixelmatch
Pillow
Expand Down
130 changes: 130 additions & 0 deletions tests/test_component.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import asyncio
import pytest

from trame_client.widgets.core import TrameComponent
from trame.decorators import change, controller, trigger
from trame.app import get_server

from trame.ui.html import DivLayout
from trame.widgets import html

CLIENT_TYPE = "vue3"


class TestComponent(TrameComponent):
def __init__(self, server):
self._steps = []
super().__init__(server)

def validate(self):
assert self._steps == [
"a changed to 1",
"a changed to 2",
"ctrl.b() # set",
"ctrl.c() # add",
]

@change("a")
def on_a(self, a, **_):
self._steps.append(f"a changed to {a}")

@controller.set("b")
def on_ctrl_b(self):
self._steps.append("ctrl.b() # set")

@controller.add("c")
def on_ctrl_c(self):
self._steps.append("ctrl.c() # add")

@trigger("hello")
def on_trigger(self): ...


class WidgetComponent(html.Div):
def __init__(self, **kwargs):
self._steps = []
super().__init__(**kwargs)
self.state.setdefault("a", 1)

with self:
html.Label("Hello World {{ a }}")

def validate(self):
assert self._steps == [
"a changed to 1",
"a changed to 2",
"ctrl.b() # set",
"ctrl.c() # add",
]

@change("a")
def on_a(self, a, **_):
self._steps.append(f"a changed to {a}")

@controller.set("b")
def on_ctrl_b(self):
self._steps.append("ctrl.b() # set")

@controller.add("c")
def on_ctrl_c(self):
self._steps.append("ctrl.c() # add")

@trigger("hello")
def on_trigger(self): ...


@pytest.mark.asyncio
async def test_component():
server = get_server("test_component", client_type=CLIENT_TYPE)
server.state.a = 1

component = TestComponent(server)

server.state.ready()
await asyncio.sleep(0.1)

with server.state:
server.state.a = 2

component.ctrl.b()
component.ctrl.c()

assert server.trigger_name(component.on_trigger) == "hello"

await asyncio.sleep(0.1)

component.validate()


@pytest.mark.asyncio
async def test_widget_as_component():
server = get_server("test_widget_as_component", client_type=CLIENT_TYPE)

with DivLayout(server) as layout:
WidgetComponent(ctx_name="test_comp")

assert (
layout.html
== """<div >
<div >
<label >
Hello World {{ a }}
</label>
</div>
</div>"""
)

server.state.ready()
await asyncio.sleep(0.1)

with server.state:
server.state.a = 2

server.controller.b()
server.controller.c()

assert server.trigger_name(server.context.test_comp.on_trigger) == "hello"

await asyncio.sleep(0.1)

server.context.test_comp.validate()
2 changes: 1 addition & 1 deletion trame_client/utils/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def to_pretty_html(html_content: str) -> str:
color = COLOR_PALETTE[int(indent / indent_step) % len(COLOR_PALETTE)]

output_lines.append(
f"{color}{' '*indent}{line.replace(' >', '>')}{BgColors.ENDC}"
f"{color}{' ' * indent}{line.replace(' >', '>')}{BgColors.ENDC}"
)
if delta > 0:
indent += compute_indent(line)
Expand Down
134 changes: 112 additions & 22 deletions trame_client/widgets/core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import sys
import logging
import inspect

from ..utils.defaults import TrameDefault
from ..utils.formatter import to_pretty_html

Expand Down Expand Up @@ -67,6 +69,10 @@
logger = logging.getLogger(__name__)


def can_be_decorated(x):
return inspect.ismethod(x) or inspect.isfunction(x)


def py2js_key(key):
return key.replace("_", "-")

Expand Down Expand Up @@ -211,7 +217,102 @@ def __call__(self, layout=None, **kwargs):
HTML_CTX.add_child(self)


class AbstractElement:
class TrameComponent:
"""
Base trame class that has access to a trame server instance
on which we provide simple accessor and method decoration capabilities.
"""

def __init__(self, server, ctx_name=None, **_):
"""
Initialize TrameComponent with its server.
Keyword arguments:
server -- the server to link to (default None)
ctx_name -- name to use to bind current instance to server.context (default None)
"""
self._server = server

if ctx_name:
self.ctx[ctx_name] = self

self._bind_annotated_methods()

@property
def server(self):
"""Return the associated trame server instance"""
return self._server

@property
def state(self):
"""Return the associated server state"""
return self.server.state

@property
def ctrl(self):
"""Return the associated server controller"""
return self.server.controller

@property
def ctx(self):
"""Return the associated server context"""
return self.server.context

def _bind_annotated_methods(self):
# Look for method decorator
for k in inspect.getmembers(self.__class__, can_be_decorated):
fn = getattr(self, k[0])

# Handle @state.change
s_translator = self.state.translator
if "_trame_state_change" in fn.__dict__:
state_change_names = fn.__dict__["_trame_state_change"]
logger.debug(
f"state.change({[f'{s_translator.translate_key(v)}' for v in state_change_names]})({k[0]})"
)
self.state.change(*[f"{v}" for v in state_change_names])(fn)

# Handle @trigger
if "_trame_trigger_names" in fn.__dict__:
trigger_names = fn.__dict__["_trame_trigger_names"]
for trigger_name in trigger_names:
logger.debug(f"trigger({trigger_name})({k[0]})")
self.server.trigger(f"{trigger_name}")(fn)

# Handle @ctrl.[add, once, add_task, set]
if "_trame_controller" in fn.__dict__:
actions = fn.__dict__["_trame_controller"]
for action in actions:
name = action.get("name")
method = action.get("method")
decorate = getattr(self.ctrl, method)
logger.debug(f"ctrl.{method}({name})({k[0]})")
decorate(name)(fn)

def _unbind_annotated_methods(self):
# Look for method decorator
for k in inspect.getmembers(self.__class__, can_be_decorated):
fn = getattr(self, k[0])

# Handle @state.change
methods_to_detach = {}
if "_trame_state_change" in fn.__dict__:
methods_to_detach.add(fn)

if methods_to_detach:
for fn_list in self.state._change_callbacks.values():
to_remove = set(fn_list) | methods_to_detach
for fn in to_remove:
fn_list.remove(fn)

# Handle @trigger
# TODO

# Handle @ctrl
# TODO


class AbstractElement(TrameComponent):
"""
A Vue component which can integrate with the rest of trame
Expand Down Expand Up @@ -266,12 +367,19 @@ class AbstractElement:
>>> print(html.Template(raw_attrs=["v-slot:item.1", 'class="bg-red"', '@click.stop="a=2"']))
... <Template v-slot:item.1 class="bg-red" @click.stop="a=2" />
Context Name:
:param ctx_name: name to attach instance to server.context if provided
"""

_next_id = 1
_debug = "--debug" in sys.argv or "-d" in sys.argv

def __init__(self, _elem_name, children=None, raw_attrs=None, **kwargs):
def __init__(
self, _elem_name, children=None, raw_attrs=None, ctx_name=None, **kwargs
):
AbstractElement._next_id += 1
self._id = AbstractElement._next_id
self._server = kwargs.get("trame_server")
Expand Down Expand Up @@ -304,6 +412,8 @@ def __init__(self, _elem_name, children=None, raw_attrs=None, **kwargs):
# Add ourself to context if any
HTML_CTX.add_child(self)

super().__init__(self._server, ctx_name=ctx_name)

def _attr_str(self):
return " ".join(self._attributes.values())

Expand Down Expand Up @@ -332,30 +442,10 @@ def register_directive(py_name, js_name=None):
# App associated to HTML element
# -------------------------------------------------------------------------

@property
def server(self):
"""Return the associated server"""
return self._server

def set_server(self, v):
"""Update the associated server"""
self._server = v

@property
def state(self):
"""Return the associated server state"""
return self.server.state

@property
def ctrl(self):
"""Return the associated server controller"""
return self.server.controller

@property
def ctx(self):
"""Return the associated server context"""
return self.server.context

# -------------------------------------------------------------------------
# Buildin API
# -------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion trame_client/widgets/trame.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ def __init__(
else:
extracts.append("value")

self._attributes["slot"] = f'v-slot="{{ { ", ".join(extracts) } }}"'
self._attributes["slot"] = f'v-slot="{{ {", ".join(extracts)} }}"'


# -----------------------------------------------------------------------------
Expand Down

0 comments on commit 53390eb

Please sign in to comment.