Skip to content

Commit 53390eb

Browse files
committed
feat(TrameComponent): provide helper class to handle method decoration
1 parent 533fbfd commit 53390eb

5 files changed

Lines changed: 245 additions & 24 deletions

File tree

tests/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pytest
2+
pytest-asyncio
23
seleniumbase
34
pixelmatch
45
Pillow

tests/test_component.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import asyncio
2+
import pytest
3+
4+
from trame_client.widgets.core import TrameComponent
5+
from trame.decorators import change, controller, trigger
6+
from trame.app import get_server
7+
8+
from trame.ui.html import DivLayout
9+
from trame.widgets import html
10+
11+
CLIENT_TYPE = "vue3"
12+
13+
14+
class TestComponent(TrameComponent):
15+
def __init__(self, server):
16+
self._steps = []
17+
super().__init__(server)
18+
19+
def validate(self):
20+
assert self._steps == [
21+
"a changed to 1",
22+
"a changed to 2",
23+
"ctrl.b() # set",
24+
"ctrl.c() # add",
25+
]
26+
27+
@change("a")
28+
def on_a(self, a, **_):
29+
self._steps.append(f"a changed to {a}")
30+
31+
@controller.set("b")
32+
def on_ctrl_b(self):
33+
self._steps.append("ctrl.b() # set")
34+
35+
@controller.add("c")
36+
def on_ctrl_c(self):
37+
self._steps.append("ctrl.c() # add")
38+
39+
@trigger("hello")
40+
def on_trigger(self): ...
41+
42+
43+
class WidgetComponent(html.Div):
44+
def __init__(self, **kwargs):
45+
self._steps = []
46+
super().__init__(**kwargs)
47+
self.state.setdefault("a", 1)
48+
49+
with self:
50+
html.Label("Hello World {{ a }}")
51+
52+
def validate(self):
53+
assert self._steps == [
54+
"a changed to 1",
55+
"a changed to 2",
56+
"ctrl.b() # set",
57+
"ctrl.c() # add",
58+
]
59+
60+
@change("a")
61+
def on_a(self, a, **_):
62+
self._steps.append(f"a changed to {a}")
63+
64+
@controller.set("b")
65+
def on_ctrl_b(self):
66+
self._steps.append("ctrl.b() # set")
67+
68+
@controller.add("c")
69+
def on_ctrl_c(self):
70+
self._steps.append("ctrl.c() # add")
71+
72+
@trigger("hello")
73+
def on_trigger(self): ...
74+
75+
76+
@pytest.mark.asyncio
77+
async def test_component():
78+
server = get_server("test_component", client_type=CLIENT_TYPE)
79+
server.state.a = 1
80+
81+
component = TestComponent(server)
82+
83+
server.state.ready()
84+
await asyncio.sleep(0.1)
85+
86+
with server.state:
87+
server.state.a = 2
88+
89+
component.ctrl.b()
90+
component.ctrl.c()
91+
92+
assert server.trigger_name(component.on_trigger) == "hello"
93+
94+
await asyncio.sleep(0.1)
95+
96+
component.validate()
97+
98+
99+
@pytest.mark.asyncio
100+
async def test_widget_as_component():
101+
server = get_server("test_widget_as_component", client_type=CLIENT_TYPE)
102+
103+
with DivLayout(server) as layout:
104+
WidgetComponent(ctx_name="test_comp")
105+
106+
assert (
107+
layout.html
108+
== """<div >
109+
<div >
110+
<label >
111+
Hello World {{ a }}
112+
</label>
113+
</div>
114+
</div>"""
115+
)
116+
117+
server.state.ready()
118+
await asyncio.sleep(0.1)
119+
120+
with server.state:
121+
server.state.a = 2
122+
123+
server.controller.b()
124+
server.controller.c()
125+
126+
assert server.trigger_name(server.context.test_comp.on_trigger) == "hello"
127+
128+
await asyncio.sleep(0.1)
129+
130+
server.context.test_comp.validate()

trame_client/utils/formatter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def to_pretty_html(html_content: str) -> str:
4141
color = COLOR_PALETTE[int(indent / indent_step) % len(COLOR_PALETTE)]
4242

4343
output_lines.append(
44-
f"{color}{' '*indent}{line.replace(' >', '>')}{BgColors.ENDC}"
44+
f"{color}{' ' * indent}{line.replace(' >', '>')}{BgColors.ENDC}"
4545
)
4646
if delta > 0:
4747
indent += compute_indent(line)

trame_client/widgets/core.py

Lines changed: 112 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import sys
22
import logging
3+
import inspect
4+
35
from ..utils.defaults import TrameDefault
46
from ..utils.formatter import to_pretty_html
57

@@ -67,6 +69,10 @@
6769
logger = logging.getLogger(__name__)
6870

6971

72+
def can_be_decorated(x):
73+
return inspect.ismethod(x) or inspect.isfunction(x)
74+
75+
7076
def py2js_key(key):
7177
return key.replace("_", "-")
7278

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

213219

214-
class AbstractElement:
220+
class TrameComponent:
221+
"""
222+
Base trame class that has access to a trame server instance
223+
on which we provide simple accessor and method decoration capabilities.
224+
"""
225+
226+
def __init__(self, server, ctx_name=None, **_):
227+
"""
228+
Initialize TrameComponent with its server.
229+
230+
Keyword arguments:
231+
server -- the server to link to (default None)
232+
ctx_name -- name to use to bind current instance to server.context (default None)
233+
"""
234+
self._server = server
235+
236+
if ctx_name:
237+
self.ctx[ctx_name] = self
238+
239+
self._bind_annotated_methods()
240+
241+
@property
242+
def server(self):
243+
"""Return the associated trame server instance"""
244+
return self._server
245+
246+
@property
247+
def state(self):
248+
"""Return the associated server state"""
249+
return self.server.state
250+
251+
@property
252+
def ctrl(self):
253+
"""Return the associated server controller"""
254+
return self.server.controller
255+
256+
@property
257+
def ctx(self):
258+
"""Return the associated server context"""
259+
return self.server.context
260+
261+
def _bind_annotated_methods(self):
262+
# Look for method decorator
263+
for k in inspect.getmembers(self.__class__, can_be_decorated):
264+
fn = getattr(self, k[0])
265+
266+
# Handle @state.change
267+
s_translator = self.state.translator
268+
if "_trame_state_change" in fn.__dict__:
269+
state_change_names = fn.__dict__["_trame_state_change"]
270+
logger.debug(
271+
f"state.change({[f'{s_translator.translate_key(v)}' for v in state_change_names]})({k[0]})"
272+
)
273+
self.state.change(*[f"{v}" for v in state_change_names])(fn)
274+
275+
# Handle @trigger
276+
if "_trame_trigger_names" in fn.__dict__:
277+
trigger_names = fn.__dict__["_trame_trigger_names"]
278+
for trigger_name in trigger_names:
279+
logger.debug(f"trigger({trigger_name})({k[0]})")
280+
self.server.trigger(f"{trigger_name}")(fn)
281+
282+
# Handle @ctrl.[add, once, add_task, set]
283+
if "_trame_controller" in fn.__dict__:
284+
actions = fn.__dict__["_trame_controller"]
285+
for action in actions:
286+
name = action.get("name")
287+
method = action.get("method")
288+
decorate = getattr(self.ctrl, method)
289+
logger.debug(f"ctrl.{method}({name})({k[0]})")
290+
decorate(name)(fn)
291+
292+
def _unbind_annotated_methods(self):
293+
# Look for method decorator
294+
for k in inspect.getmembers(self.__class__, can_be_decorated):
295+
fn = getattr(self, k[0])
296+
297+
# Handle @state.change
298+
methods_to_detach = {}
299+
if "_trame_state_change" in fn.__dict__:
300+
methods_to_detach.add(fn)
301+
302+
if methods_to_detach:
303+
for fn_list in self.state._change_callbacks.values():
304+
to_remove = set(fn_list) | methods_to_detach
305+
for fn in to_remove:
306+
fn_list.remove(fn)
307+
308+
# Handle @trigger
309+
# TODO
310+
311+
# Handle @ctrl
312+
# TODO
313+
314+
315+
class AbstractElement(TrameComponent):
215316
"""
216317
A Vue component which can integrate with the rest of trame
217318
@@ -266,12 +367,19 @@ class AbstractElement:
266367
267368
>>> print(html.Template(raw_attrs=["v-slot:item.1", 'class="bg-red"', '@click.stop="a=2"']))
268369
... <Template v-slot:item.1 class="bg-red" @click.stop="a=2" />
370+
371+
Context Name:
372+
373+
:param ctx_name: name to attach instance to server.context if provided
374+
269375
"""
270376

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

274-
def __init__(self, _elem_name, children=None, raw_attrs=None, **kwargs):
380+
def __init__(
381+
self, _elem_name, children=None, raw_attrs=None, ctx_name=None, **kwargs
382+
):
275383
AbstractElement._next_id += 1
276384
self._id = AbstractElement._next_id
277385
self._server = kwargs.get("trame_server")
@@ -304,6 +412,8 @@ def __init__(self, _elem_name, children=None, raw_attrs=None, **kwargs):
304412
# Add ourself to context if any
305413
HTML_CTX.add_child(self)
306414

415+
super().__init__(self._server, ctx_name=ctx_name)
416+
307417
def _attr_str(self):
308418
return " ".join(self._attributes.values())
309419

@@ -332,30 +442,10 @@ def register_directive(py_name, js_name=None):
332442
# App associated to HTML element
333443
# -------------------------------------------------------------------------
334444

335-
@property
336-
def server(self):
337-
"""Return the associated server"""
338-
return self._server
339-
340445
def set_server(self, v):
341446
"""Update the associated server"""
342447
self._server = v
343448

344-
@property
345-
def state(self):
346-
"""Return the associated server state"""
347-
return self.server.state
348-
349-
@property
350-
def ctrl(self):
351-
"""Return the associated server controller"""
352-
return self.server.controller
353-
354-
@property
355-
def ctx(self):
356-
"""Return the associated server context"""
357-
return self.server.context
358-
359449
# -------------------------------------------------------------------------
360450
# Buildin API
361451
# -------------------------------------------------------------------------

trame_client/widgets/trame.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ def __init__(
193193
else:
194194
extracts.append("value")
195195

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

198198

199199
# -----------------------------------------------------------------------------

0 commit comments

Comments
 (0)