Widget
machinable.widget.Widget (inherits Interface) · source
An Interface that ships an anywidget-compatible frontend (_esm/_css).
push_widget_change()
push_widget_change(self, changes: 'dict') -> 'None'Push server-originated trait changes to connected widget clients.
push_widget_message()
push_widget_message(self, content: 'Any') -> 'None'Push a server-originated custom message to connected widget clients.
widget_message()
widget_message(self, content: 'Any', buffers: 'list | None' = None) -> 'Any'Handle a custom model.send message; return an optional reply.
widget_state()
widget_state(self) -> 'dict'Full initial synced state (the anywidget model).
Defaults to the resolved configuration as a read-only view. Config is immutable post-materialization, so interactive widgets that need mutable state should override widget_update and keep it themselves, via save_file (durable) or mark (in-memory, per session), and never mutate config.
widget_update()
widget_update(self, changes: 'dict') -> 'dict'Apply client changes and return the normalized state.
Rejects by default: config is immutable identity. Override to accept mutable state and keep it with save_file (durable) or mark (in-memory).
Source
src/machinable/widget.py
"""An Interface that ships an anywidget-compatible frontend.
Renderable in Jupyter and consumable by any web host.
The frontend contract (``render({ model, el })`` + CSS) and the ``model`` object
are anywidget's; machinable's contribution is backing that model with a durable,
indexed, searchable Interface (config, storage, executions) and serving the same
widget to non-notebook hosts. See the Widgets guide in the machinable documentation.
``kind`` stays ``"Interface"`` (it is reserved and must resolve to a schema
class); widget-ness is a declared attribute, not a new kind.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from typing import Any
from omegaconf import OmegaConf
from machinable.errors import MachinableError
from machinable.interface import Interface
class Widget(Interface):
# ES module exporting ``render({ model, el })`` (+ optional ``css``); inline
# source or a path resolved relative to the defining module file.
"""An Interface that ships an anywidget-compatible frontend (``_esm``/``_css``)."""
_esm: str | Path = ""
# optional stylesheet (anywidget's ``_css``); inline source or sibling path.
_css: str | Path | None = None
# OPAQUE to machinable; consumers stash their manifest here and machinable
# never parses it (e.g. captu: role/consumes/produces/capabilities).
widget_meta: dict = {}
def widget_state(self) -> dict:
"""Full initial synced state (the anywidget ``model``).
Defaults to the resolved configuration as a **read-only** view. Config
is immutable post-materialization, so interactive widgets that need
mutable state should override :meth:`widget_update` and keep it
themselves, via :meth:`save_file` (durable) or :meth:`mark`
(in-memory, per session), and never mutate config.
"""
if self.config is None:
return {}
resolved = OmegaConf.to_container(self.config, resolve=True)
if not isinstance(resolved, dict):
return {}
return {k: v for k, v in resolved.items() if not str(k).startswith("_")}
def widget_update(self, changes: dict) -> dict:
"""Apply client changes and return the normalized state.
Rejects by default: config is immutable identity. Override to accept
mutable state and keep it with :meth:`save_file` (durable) or
:meth:`mark` (in-memory).
"""
raise MachinableError(
"This widget is read-only. Override widget_update() and keep "
"mutable state via save_file() or mark()."
)
def widget_message(self, content: Any, buffers: list | None = None) -> Any:
"""Handle a custom ``model.send`` message; return an optional reply."""
return None
# ── server → client pushes (over the existing emit hook) ──────────────────
def push_widget_change(self, changes: dict) -> None:
"""Push server-originated trait changes to connected widget clients."""
self.emit({"__widget__": {"kind": "change", "changes": changes}})
def push_widget_message(self, content: Any) -> None:
"""Push a server-originated custom message to connected widget clients."""
self.emit({"__widget__": {"kind": "msg", "content": content}})
# ── Jupyter rendering (anywidget) ─────────────────────────────────────────
def _repr_mimebundle_(self, include=None, exclude=None, **kwargs):
view = self._anywidget_view()
return view._repr_mimebundle_(include=include, exclude=exclude, **kwargs)
def _anywidget_view(self):
try:
import anywidget
import traitlets
except ImportError as ex: # pragma: no cover - optional dependency
raise ImportError(
"anywidget is required for notebook rendering. "
"Install with: pip install 'machinable[widgets]'"
) from ex
state = self.widget_state()
attrs: dict[str, Any] = {"_esm": widget_esm(type(self)) or ""}
css = widget_css(type(self))
if css is not None:
attrs["_css"] = css
for key, value in state.items():
attrs[key] = traitlets.Any(value).tag(sync=True)
view_cls = type("MachinableWidgetView", (anywidget.AnyWidget,), attrs)
view = view_cls()
def _on_change(change) -> None:
try:
self.widget_update({change["name"]: change["new"]})
except Exception:
# read-only widgets reject; keep the notebook view responsive
pass
for key in state:
view.observe(_on_change, names=key)
view.on_msg(lambda _w, content, buffers: self.widget_message(content, buffers))
return view
# ── detection + asset resolution (no FastAPI dependency; usable in notebooks) ──
def is_widget(obj: Any) -> bool:
"""True when ``obj`` (class or instance) declares an anywidget frontend."""
cls = obj if isinstance(obj, type) else type(obj)
if isinstance(cls, type) and issubclass(cls, Widget):
return bool(getattr(cls, "_esm", ""))
return bool(getattr(cls, "_esm", "")) and (
isinstance(cls, type) and issubclass(cls, Interface)
)
def _resolve_asset(cls: type, value: str | Path | None) -> str | None:
"""Return asset source: inline string as-is, or read a sibling file.
A string is treated as a sibling path when (and only when) it resolves to an
existing file relative to the defining module; otherwise it is inline
source. ``Path`` values are always read as files.
"""
if value is None or value == "":
return None
module = sys.modules.get(cls.__module__)
base = os.path.dirname(getattr(module, "__file__", "") or "")
if isinstance(value, Path):
path = value if value.is_absolute() else Path(base) / value
return path.read_text(encoding="utf-8")
candidate = os.path.join(base, value)
if "\n" not in value and len(value) < 4096 and os.path.isfile(candidate):
with open(candidate, encoding="utf-8") as handle:
return handle.read()
return value
def widget_esm(cls: type) -> str | None:
"""The widget's ES module source, or ``None``."""
return _resolve_asset(cls, getattr(cls, "_esm", None))
def widget_css(cls: type) -> str | None:
"""The widget's stylesheet source, or ``None``."""
return _resolve_asset(cls, getattr(cls, "_css", None))2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162