Coverage for src/loman/ui/widget.py: 99%
532 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-07 00:32 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-07 00:32 +0000
1"""AnyWidget implementation for live Loman computation graphs."""
3from __future__ import annotations
5import functools
6import logging
7from collections import OrderedDict, deque
8from collections.abc import Callable
9from contextlib import contextmanager
10from pathlib import Path
11from typing import TYPE_CHECKING, Any, TypedDict
13import anywidget
14import traitlets
16from loman.computeengine import ComputationEvent
17from loman.consts import NodeTransformations, States
18from loman.nodekey import Name, NodeKey, to_nodekey
20from .builder import GraphBuildError, build_definition, relative_name, resolve_name
21from .value import ValueWireError, apply_cell_edit, from_wire
22from .viewmodel import build_detail, node_states, state_colors
24if TYPE_CHECKING:
25 from collections.abc import Iterator
27 from loman.computeengine import Computation
28 from loman.visualization import GraphView
30LOG = logging.getLogger("loman.ui.widget")
32_STATIC = Path(__file__).parent / "static"
34#: How many recent browser request IDs to remember. Requests carry a nonce so
35#: that repeating an identical action still registers as a trait change; keeping
36#: the recent ones lets a widget ignore a request a reconnecting or replayed
37#: front-end model pushes back at it. Small on purpose: this guards against
38#: accidental replay, not against a determined caller.
39_REQUEST_HISTORY = 64
41#: Default ceiling on how many nodes one expand request may put on screen.
42#: Graphviz output measures at roughly 0.6 KiB and 0.6 ms of ``dot`` time per
43#: rendered node, both linear, so 500 nodes is about 300 KiB and a third of a
44#: second per relayout --- slow but usable. Beyond that a single click can hang
45#: the kernel for seconds and push a megabyte at the browser, so opening a block
46#: that large is refused rather than merely sluggish. Raise it with
47#: ``max_rendered_nodes=`` if you know what you are asking for.
48DEFAULT_MAX_RENDERED_NODES = 500
50#: How many node names the widget will offer the browser as suggestions while
51#: someone types an input into the node form. Names are small, but a graph with
52#: more nodes than this would send a list larger than the picture it belongs
53#: to, for a convenience --- so past it the form simply stops suggesting.
54MAX_NAME_SUGGESTIONS = 500
56#: Sentinel from :meth:`ComputationWidget._canonical_output`: the trait is
57#: view-dependent and no view exists yet, so its echoed value is left alone.
58_NO_CANONICAL = object()
60#: How many laid-out views to keep, keyed by what the layout depends on.
61#:
62#: Navigating is a walk down one path and back up it: open a block, look, come
63#: back out. The way back is the part that should cost nothing, because it is
64#: retracing ground already covered, and a dozen entries covers a deeper stack
65#: than anyone navigates by hand. Entries are Graphviz SVGs at roughly 0.6 KiB
66#: per rendered node, so the ceiling is a few hundred KiB.
67#:
68#: Rendering the layer below before it is asked for was the alternative, and it
69#: costs one ``dot`` run per block on screen for views most people never open.
70#: The trip back is the one that is certain to happen.
71_LAYOUT_CACHE_SIZE = 12
74def _acknowledges(method: Callable[[Any, dict[str, Any]], None]) -> Callable[[Any, dict[str, Any]], None]:
75 """Acknowledge a browser request once the observer has finished with it."""
77 @functools.wraps(method)
78 def wrapped(self: ComputationWidget, change: dict[str, Any]) -> None:
79 """Run the observer, then bump the acknowledgement counter."""
80 try:
81 method(self, change)
82 finally:
83 self._acknowledge()
85 return wrapped
88class _DrawOptions(TypedDict):
89 """Keyword options forwarded to :meth:`Computation.draw`."""
91 cmap: Any
92 colors: str
93 shapes: str | None
94 graph_attr: dict[str, Any] | None
95 node_attr: dict[str, Any] | None
96 edge_attr: dict[str, Any] | None
97 show_expansion: bool
98 collapse_all: bool
101class ComputationWidget(anywidget.AnyWidget):
102 """Interactive graph view that automatically follows a computation.
104 The widget subscribes to its computation and repaints as that computation
105 changes. It navigates and lightly controls; the real object stays in Python,
106 so ``comp.v[widget.selected_name]`` remains the way to get at a value.
108 Two costs are worth knowing about:
110 * Computation happens synchronously in the kernel, inside the observer that
111 handles the request. A slow graph freezes the widget, so drive long
112 computations from an ordinary cell and let the widget observe the result.
113 * With the default ``colors="state"`` a state change repaints existing SVG
114 shapes in place. Any other colouring, such as ``colors="timing"``, depends
115 on values rather than states, so every mutation re-runs Graphviz --- one
116 ``dot`` subprocess per change.
118 On lifetime: the computation subscribes to a bound method and so holds only
119 a weak reference, meaning it never keeps a widget alive by itself. ipywidgets
120 is the one that does --- it registers every open widget in a process-wide
121 table until the widget is closed. Call :meth:`close` when you are finished
122 with a widget; that both unsubscribes it and releases it.
123 """
125 _esm = _STATIC / "widget.js"
126 _css = _STATIC / "widget.css"
128 graph_svg = traitlets.Unicode("").tag(sync=True)
129 node_states = traitlets.Dict(default_value={}).tag(sync=True)
130 state_colors = traitlets.Dict(default_value={}).tag(sync=True)
131 composite_ids = traitlets.List(traitlets.Unicode(), default_value=[]).tag(sync=True)
132 selected_id = traitlets.Unicode("").tag(sync=True)
133 detail = traitlets.Dict(default_value={}).tag(sync=True)
134 status = traitlets.Unicode("").tag(sync=True)
135 status_severity = traitlets.Unicode("idle").tag(sync=True)
136 #: Bumped after every browser request, whatever the outcome. The front end
137 #: shows an optimistic busy state while it waits, and a request can
138 #: legitimately change nothing else --- collapsing an already-collapsed
139 #: graph re-renders identical SVG and re-reports an identical status, so
140 #: neither trait fires and the browser would wait for ever.
141 ack = traitlets.Int(0).tag(sync=True)
142 expanded_paths = traitlets.List(traitlets.Unicode(), default_value=[]).tag(sync=True)
143 editable = traitlets.Bool(True).tag(sync=True)
144 #: Permit building the graph itself --- adding, redefining, renaming and
145 #: deleting nodes --- from the toolbar and the detail panel. Off by
146 #: default, and deliberately separate from :attr:`editable`: defining a
147 #: calculation node means running an expression typed in the browser inside
148 #: the kernel, which is a different thing to agree to than editing a value.
149 buildable = traitlets.Bool(False).tag(sync=True)
150 #: Every node name in the computation, in the same relative form the node
151 #: form accepts, so the browser can suggest inputs as they are typed. Empty
152 #: past :data:`MAX_NAME_SUGGESTIONS` nodes.
153 node_names = traitlets.List(traitlets.Unicode(), default_value=[]).tag(sync=True)
154 #: Scale the graph down to fit the pane whenever it is re-rendered, if it
155 #: would otherwise overflow. Off by default: a large graph fitted into a
156 #: notebook pane is unreadable, and reading is the usual reason to open one.
157 fit_on_render = traitlets.Bool(False).tag(sync=True)
158 repaint_states = traitlets.Bool(True).tag(sync=True)
159 revision = traitlets.Int(0).tag(sync=True)
160 #: Graphviz layout direction. Defaults to ``LR`` because computations read
161 #: left to right, from inputs to results; the toolbar toggles it to ``TB``.
162 rankdir = traitlets.Unicode("LR").tag(sync=True)
163 #: Breadcrumb from the widget's own root down to the block in focus. Each
164 #: entry is ``{"label": str, "path": str}``; the front end renders it and
165 #: sends a focus_request to climb back up.
166 focus_trail = traitlets.List(traitlets.Dict(), default_value=[]).tag(sync=True)
167 #: Name of the node whose full value the user asked to see, or ``""``.
168 #:
169 #: The widget only ever sends a window of a large value, and it cannot call
170 #: the host's own renderers --- it is a host-neutral AnyWidget, and reaching
171 #: into marimo would make the extra depend on it and break Jupyter. So the
172 #: "Show full" button publishes the request here instead, and the notebook
173 #: renders it with whatever it likes::
174 #:
175 #: _ = widget_ui.value # react to the button
176 #: mo.ui.table(widget.full_view_value) if widget.full_view else None
177 #:
178 #: This trait is the *label*, and a label is lossy: a node called ``1`` and
179 #: a node called ``"1"`` share one. Fetch through :attr:`full_view_value`,
180 #: or read :attr:`full_view_name` for the name with its original type.
181 full_view = traitlets.Unicode("").tag(sync=True)
183 edit_request = traitlets.Dict(default_value={}).tag(sync=True)
184 compute_request = traitlets.Dict(default_value={}).tag(sync=True)
185 toggle_request = traitlets.Dict(default_value={}).tag(sync=True)
186 layout_request = traitlets.Dict(default_value={}).tag(sync=True)
187 focus_request = traitlets.Dict(default_value={}).tag(sync=True)
188 full_view_request = traitlets.Dict(default_value={}).tag(sync=True)
189 graph_request = traitlets.Dict(default_value={}).tag(sync=True)
191 def __init__(
192 self,
193 computation: Computation,
194 root: NodeKey | None = None,
195 *,
196 node_transformations: dict[Name, str] | None = None,
197 cmap: Any = None,
198 colors: str = "state",
199 shapes: str | None = None,
200 graph_attr: dict[str, Any] | None = None,
201 node_attr: dict[str, Any] | None = None,
202 edge_attr: dict[str, Any] | None = None,
203 show_expansion: bool = False,
204 collapse_all: bool = True,
205 editable: bool = True,
206 buildable: bool = False,
207 namespace: dict[str, Any] | None = None,
208 fit_on_render: bool = False,
209 max_rendered_nodes: int = DEFAULT_MAX_RENDERED_NODES,
210 rankdir: str = "LR",
211 ) -> None:
212 """Create a widget and subscribe it to ``computation``.
214 Arguments other than ``editable``, ``buildable``, ``namespace``
215 and ``max_rendered_nodes`` mirror :meth:`Computation.draw`.
217 :param editable: Permit scalar input edits and computation controls.
218 Expanding and collapsing blocks stays available either way, because
219 navigating a graph does not mutate it.
220 :param buildable: Permit building the graph in the widget: adding,
221 redefining, renaming and deleting nodes. Off by default, and needs
222 ``editable`` as well. Defining a calculation node compiles and runs
223 an expression typed in the browser, in the kernel, with whatever
224 ``namespace`` gives it --- so it is opt-in rather than implied by
225 being able to edit a value.
226 :param namespace: Globals a node built in the widget is compiled
227 against, so its expression can use the notebook's own imports. Pass
228 ``globals()`` for that. The default is an empty namespace, where
229 only builtins are in scope.
230 :param max_rendered_nodes: Refuse an expand request that would put more
231 than this many nodes on screen. It does not cap the initial view:
232 what you asked to draw is drawn.
233 :param rankdir: Initial Graphviz layout direction, ``LR`` (default) or
234 ``TB``. A ``rankdir`` given in ``graph_attr`` takes precedence. The
235 toolbar toggles it live either way.
236 """
237 self.computation = computation
238 self._root = root
239 self._base_root = root
240 self._base_transformations = {} if node_transformations is None else node_transformations.copy()
241 self._expanded: set[NodeKey] = set()
242 self._draw_options: _DrawOptions = {
243 "cmap": cmap,
244 "colors": colors,
245 "shapes": shapes,
246 "graph_attr": graph_attr,
247 "node_attr": node_attr,
248 "edge_attr": edge_attr,
249 "show_expansion": show_expansion,
250 "collapse_all": collapse_all,
251 }
252 self._view: GraphView | None = None
253 self._layout_cache: OrderedDict[tuple[Any, ...], tuple[GraphView, str]] = OrderedDict()
254 self._id_to_visible: dict[str, NodeKey] = {}
255 self._canonical_graph_svg = ""
256 self._canonical_status = ""
257 self._canonical_severity = "idle"
258 self._canonical_ack = 0
259 self._canonical_full_view = ""
260 # The key behind full_view. The trait must be a string to sync, and str
261 # is lossy over node names, so the value is fetched by this instead.
262 self._full_view_key: NodeKey | None = None
263 # An explicit rankdir in graph_attr wins, so a caller who set the layout
264 # direction the old way still gets what they asked for; otherwise the
265 # left-to-right default applies.
266 self._canonical_rankdir = str(graph_attr["rankdir"]) if graph_attr and "rankdir" in graph_attr else rankdir
267 self._seen_requests: deque[str] = deque(maxlen=_REQUEST_HISTORY)
268 self._max_rendered_nodes = max_rendered_nodes
269 self._namespace = namespace
270 self._writing = 0
271 self._unsubscribe: Callable[[], None] | None = None
272 super().__init__(
273 editable=editable,
274 buildable=buildable,
275 fit_on_render=fit_on_render,
276 repaint_states=colors == "state",
277 rankdir=self._canonical_rankdir,
278 )
279 custom_colors = cmap if colors == "state" and isinstance(cmap, dict) else None
280 self.state_colors = state_colors(custom_colors)
281 self.refresh()
282 self._unsubscribe = computation.subscribe(self._on_computation_event)
284 @property
285 def selected_names(self) -> list[Name]:
286 """Return the real Loman names represented by the selected shape.
288 A collapsed block reports every member; an ordinary node reports one
289 name; nothing selected reports an empty list.
290 """
291 if self._view is None:
292 return []
293 visible = self._id_to_visible.get(self.selected_id)
294 if visible is None:
295 return []
296 return [node.name for node in self._view.original_nodes[visible]]
298 @property
299 def selected_name(self) -> Name | None:
300 """Return the selected Loman name, or block path for a composite."""
301 names = self.selected_names
302 if len(names) == 1:
303 return names[0]
304 visible = self._id_to_visible.get(self.selected_id)
305 return None if visible is None else self._full_visible_key(visible).name
307 @property
308 def selected(self) -> Name | None:
309 """Alias for :attr:`selected_name`."""
310 return self.selected_name
312 def _full_visible_key(self, visible: NodeKey) -> NodeKey:
313 """Restore a rooted view key to its full computation path."""
314 return visible if self._root is None else to_nodekey(self._root).join(visible)
316 def _focus_trail(self) -> list[dict[str, str]]:
317 """Describe the path from the widget's own root to the block in focus.
319 The first entry is the widget's root, labelled ``Reset`` when the whole
320 computation is in view; each further entry is one block descended into.
321 Paths are full computation paths, so the front end can hand any of them
322 straight back as a focus_request.
323 """
324 base = None if self._base_root is None else to_nodekey(self._base_root)
325 current = None if self._root is None else to_nodekey(self._root)
326 trail = [{"label": "Reset" if base is None else base.label, "path": "" if base is None else str(base)}]
327 if current is None or current == base:
328 return trail
329 relative = current.drop_root(base)
330 acc = base if base is not None else NodeKey.root()
331 for part in relative.parts: # type: ignore[union-attr]
332 acc = acc.join_parts(part)
333 trail.append({"label": str(part), "path": str(acc)})
334 return trail
336 def _make_view(self) -> GraphView:
337 """Create the current GraphView, including interactive expansions."""
338 transformations = self._base_transformations.copy()
339 transformations.update(dict.fromkeys(self._expanded, NodeTransformations.EXPAND))
340 options = dict(self._draw_options)
341 graph_attr = dict(options["graph_attr"] or {})
342 graph_attr["rankdir"] = self.rankdir
343 # Graphviz paints an opaque white page into the SVG by default, which
344 # is a sheet of paper dropped onto whatever the host is themed as.
345 # Left transparent, the graph sits on the host's own background.
346 # ``setdefault``, so a caller who asked for a colour still gets it.
347 graph_attr.setdefault("bgcolor", "transparent")
348 options["graph_attr"] = graph_attr
349 return self.computation.draw(
350 self._root,
351 node_transformations=transformations,
352 **options, # type: ignore[arg-type]
353 )
355 @contextmanager
356 def _own_write(self) -> Iterator[None]:
357 """Mark trait writes as Python's own, so echo checking can skip them."""
358 self._writing += 1
359 try:
360 yield
361 finally:
362 self._writing -= 1
364 def _set_status(self, text: str, severity: str = "success") -> None:
365 """Set the status line and record it as Python's canonical value.
367 Status must go through here rather than being assigned directly. When
368 the browser sends a request, ipywidgets applies the whole incoming state
369 inside one ``hold_trait_notifications`` block: our observer runs and sets
370 a status, and the browser's own stale copy of ``status`` is then applied
371 on top, silently reverting it. Recording the value here lets
372 :meth:`_canonical_output_changed` put it back.
374 :param text: Message to show.
375 :param severity: ``success``, ``error`` or ``idle``. Sent explicitly so
376 the front end styles the message rather than guessing from its
377 wording.
378 """
379 self._canonical_status = text
380 self._canonical_severity = "idle" if not text else severity
381 with self._own_write():
382 self.status = text
383 self.status_severity = self._canonical_severity
385 def _fail(self, text: str) -> None:
386 """Report a failed request on the status line."""
387 self._set_status(text, "error")
389 def _acknowledge(self) -> None:
390 """Tell the browser a request has been dealt with.
392 Sent unconditionally, because "nothing changed" is a real outcome and
393 the front end cannot distinguish it from "still working" otherwise.
394 """
395 self._canonical_ack += 1
396 with self._own_write():
397 self.ack = self._canonical_ack
399 def _layout_key(self) -> tuple[Any, ...] | None:
400 """Return what the layout about to be drawn depends on, or ``None``.
402 ``None`` means this layout must not be cached. With any colouring other
403 than by state the fills come from values rather than states, so an
404 otherwise identical view can want a different picture without the
405 structure having moved. Colouring by state repaints in the browser, so
406 a stored layout stays correct as the computation runs.
407 """
408 if not self.repaint_states:
409 return None
410 return (
411 str(self._root),
412 tuple(sorted(str(block) for block in self._expanded)),
413 self.rankdir,
414 )
416 def _layout(self) -> tuple[GraphView, str]:
417 """Lay the current view out, reusing a stored one where that is sound.
419 Structural changes clear the cache outright rather than being part of
420 the key, because a stored :class:`GraphView` describes a shape of graph
421 that no longer exists once nodes come and go.
422 """
423 key = self._layout_key()
424 if key is not None and key in self._layout_cache:
425 self._layout_cache.move_to_end(key)
426 return self._layout_cache[key]
427 view = self._make_view()
428 result = (view, view.svg() or "")
429 if key is not None:
430 self._layout_cache[key] = result
431 while len(self._layout_cache) > _LAYOUT_CACHE_SIZE:
432 self._layout_cache.popitem(last=False)
433 return result
435 def refresh(self) -> bool:
436 """Force a full graph refresh, including SVG layout and node identity.
438 This is the explicit escape hatch for changes the subscription cannot
439 see, such as mutating ``computation.dag`` directly. Stored layouts are
440 discarded, since a change the widget could not see is exactly the kind
441 that invalidates them.
443 :return: True on success. On failure the previous picture is left alone
444 and :attr:`status` explains what went wrong.
445 """
446 self._layout_cache.clear()
447 return self._redraw()
449 def _redraw(self) -> bool:
450 """Re-render from the current root and expansions, reusing layouts.
452 :return: True on success, leaving the previous picture alone otherwise.
453 """
454 selected_members: frozenset[NodeKey] | None = None
455 if self._view is not None and self.selected_id:
456 selected_visible = self._id_to_visible.get(self.selected_id)
457 if selected_visible is not None:
458 selected_members = frozenset(self._view.original_nodes[selected_visible])
459 try:
460 view, svg = self._layout()
461 except Exception as exc:
462 # Rendering shells out to ``dot``; a missing binary, an unwriteable
463 # temp dir or a malformed attribute all surface here, and none of
464 # them should propagate out of a traitlets observer.
465 LOG.exception("Loman widget could not render the computation graph")
466 self._fail(f"Unable to render graph: {type(exc).__name__}: {exc}")
467 return False
468 with self._own_write():
469 self._view = view
470 self._id_to_visible = {node_id: visible for visible, node_id in view.node_index_map.items()}
471 self._canonical_graph_svg = svg
472 self.node_states = node_states(view)
473 self.composite_ids = [view.node_index_map[node] for node in view.composite_nodes]
474 # An expanded block is drawn as a Graphviz cluster, not a node, so
475 # there is no shape left to click to close it again. Naming the open
476 # blocks lets the front end make their cluster labels the handle.
477 self.expanded_paths = sorted(str(block) for block in self._expanded)
478 self.focus_trail = self._focus_trail()
479 self.node_names = self._node_names()
480 self.revision = self.computation.revision
481 if self.selected_id:
482 selected_visible = self._id_to_visible.get(self.selected_id)
483 refreshed = None if selected_visible is None else frozenset(view.original_nodes[selected_visible])
484 if refreshed != selected_members:
485 # A relayout reuses rendered IDs, so the shape this ID now
486 # names may be a different node. Drop the selection rather
487 # than silently move it.
488 self.selected_id = ""
489 self.graph_svg = svg
490 self._refresh_detail()
491 return True
493 def _node_names(self) -> list[str]:
494 """Name every node in the computation, for the node form's suggestions.
496 Names are relative to the view's root, exactly as the form reads them
497 back, so a suggestion can be accepted as typed.
498 """
499 if len(self.computation.dag) > MAX_NAME_SUGGESTIONS:
500 return []
501 root = None if self._root is None else to_nodekey(self._root)
502 return sorted(relative_name(node_key, root) for node_key in self.computation.dag.nodes)
504 def _detail_for(self, node_id: str) -> dict[str, Any]:
505 """Build the detail payload for one rendered node ID."""
506 if self._view is None:
507 return {}
508 return build_detail(
509 self._view,
510 node_id,
511 editable=self.editable,
512 id_to_visible=self._id_to_visible,
513 root=None if self._root is None else to_nodekey(self._root),
514 )
516 def _refresh_detail(self) -> None:
517 """Refresh the selected node's lazy detail payload."""
518 with self._own_write():
519 self.detail = self._detail_for(self.selected_id)
521 def _on_computation_event(self, event: ComputationEvent) -> None:
522 """Apply an automatic incremental or structural computation update."""
523 if event.graph_changed or not self.repaint_states or self._view is None:
524 self.refresh()
525 return
526 with self._own_write():
527 self.node_states = node_states(self._view)
528 self.revision = event.revision
529 visible = self._id_to_visible.get(self.selected_id)
530 if visible is not None and not set(self._view.original_nodes[visible]).isdisjoint(event.changed_nodes):
531 self._refresh_detail()
533 def _claim_request(self, request: dict[str, Any]) -> bool:
534 """Report whether a browser request is new rather than a replay.
536 Every request carries a ``request_id`` nonce so that repeating the same
537 action still reads as a trait change. Remembering the recent ones stops
538 a reconnecting or recreated front-end model from re-applying an edit or
539 a compute it already had in hand.
540 """
541 request_id = request.get("request_id")
542 if request_id is None:
543 return True
544 if request_id in self._seen_requests:
545 LOG.debug("Ignoring replayed Loman widget request %s", request_id)
546 return False
547 self._seen_requests.append(request_id)
548 return True
550 @traitlets.observe("selected_id")
551 def _selected_changed(self, _change: dict[str, Any]) -> None:
552 """Populate details when the browser selects a rendered node."""
553 if hasattr(self, "_view"):
554 self._refresh_detail()
556 def _canonical_output(self, name: str) -> Any:
557 """Return the value trait ``name`` should hold.
559 Split from :meth:`_canonical_output_changed` to keep that observer flat:
560 this owns the per-trait lookup. Returns :data:`_NO_CANONICAL` when the
561 trait is view-dependent and no view has been rendered yet, so its echoed
562 value is left untouched rather than reverted to nothing.
564 :param name: Name of the derived trait being checked.
565 :return: The canonical value, or :data:`_NO_CANONICAL`.
566 """
567 view_independent: dict[str, Callable[[], Any]] = {
568 "ack": lambda: self._canonical_ack,
569 "status": lambda: self._canonical_status,
570 "status_severity": lambda: self._canonical_severity,
571 "rankdir": lambda: self._canonical_rankdir,
572 "focus_trail": self._focus_trail,
573 "full_view": lambda: self._canonical_full_view,
574 "node_names": self._node_names,
575 }
576 if name in view_independent:
577 return view_independent[name]()
578 if self._view is None:
579 return _NO_CANONICAL
580 view = self._view
581 view_dependent: dict[str, Callable[[], Any]] = {
582 "composite_ids": lambda: [view.node_index_map[node] for node in view.composite_nodes],
583 "detail": lambda: self._detail_for(self.selected_id),
584 "expanded_paths": lambda: sorted(str(block) for block in self._expanded),
585 "graph_svg": lambda: self._canonical_graph_svg,
586 "node_states": lambda: node_states(view),
587 "revision": lambda: self.computation.revision,
588 }
589 return view_dependent[name]()
591 @traitlets.observe(
592 "ack",
593 "composite_ids",
594 "detail",
595 "expanded_paths",
596 "focus_trail",
597 "full_view",
598 "graph_svg",
599 "node_names",
600 "node_states",
601 "rankdir",
602 "revision",
603 "status",
604 "status_severity",
605 )
606 def _canonical_output_changed(self, change: dict[str, Any]) -> None:
607 """Reject stale derived traits echoed by the browser model.
609 These traits are Python's to own. The browser never writes them
610 deliberately, but every message it sends carries its cached copy of the
611 model, and ipywidgets applies that inside one
612 ``hold_trait_notifications`` block --- so a value the browser captured
613 before our observer ran lands *after* it, reverting the update. The same
614 thing happens on reconnect. Anything that does not match is put back.
615 """
616 if self._writing or not hasattr(self, "_canonical_status"):
617 return
618 name = change["name"]
619 expected = self._canonical_output(name)
620 if expected is _NO_CANONICAL or change["new"] == expected:
621 return
622 with self._own_write():
623 setattr(self, name, expected)
625 @traitlets.observe("edit_request")
626 @_acknowledges
627 def _edit_requested(self, change: dict[str, Any]) -> None:
628 """Validate and apply one edit requested by the browser.
630 Two shapes arrive here: replacing a scalar node's value outright, and
631 replacing a single cell of a tabular one.
632 """
633 request = change["new"]
634 if not request or not hasattr(self, "_id_to_visible") or not self._claim_request(request):
635 return
636 if not self.editable:
637 self._fail("Edit failed: this widget is read-only")
638 return
639 if self._view is None:
640 self._fail("Edit failed: the graph is not rendered")
641 return
642 try:
643 visible = self._id_to_visible[request["id"]]
644 members = self._view.original_nodes[visible]
645 if len(members) != 1:
646 self._fail("Edit failed: collapsed blocks cannot be edited")
647 return
648 current_detail = self._detail_for(request["id"])
649 cell = request.get("cell")
650 if cell is not None:
651 if not current_detail.get("cells_editable"):
652 self._fail("Edit failed: this node's cells are not editable")
653 return
654 node_key = members[0]
655 updated = apply_cell_edit(
656 self.computation.value(node_key),
657 int(cell["row"]),
658 int(cell["column"]),
659 from_wire(request["value"]),
660 )
661 self.computation.insert(node_key, updated)
662 self._set_status(f"Updated {node_key} [{cell['row']}, {cell['column']}]")
663 return
664 if not current_detail.get("editable"):
665 self._fail("Edit failed: this node is not an editable scalar input")
666 return
667 self.computation.insert(members[0], from_wire(request["value"]))
668 self._set_status(f"Updated {members[0]}")
669 except Exception as exc:
670 # insert() rejects placeholder and missing nodes, and from_wire()
671 # rejects malformed payloads. Raising here would surface only in the
672 # kernel log and leave the UI looking like nothing happened.
673 LOG.debug("Loman widget edit request failed", exc_info=True)
674 self._fail(f"Edit failed: {type(exc).__name__}: {exc}")
676 @traitlets.observe("compute_request")
677 @_acknowledges
678 def _compute_requested(self, change: dict[str, Any]) -> None:
679 """Compute a selected target or the whole graph."""
680 request = change["new"]
681 if not request or not hasattr(self, "_id_to_visible") or not self._claim_request(request):
682 return
683 if not self.editable:
684 self._fail("Compute failed: this widget is read-only")
685 return
686 try:
687 if request.get("all"):
688 self.computation.compute_all()
689 self._set_status("Computed all available nodes")
690 return
691 if request.get("path"):
692 # Named by path rather than by rendered ID because the block in
693 # question is the one being looked inside, and so is not itself
694 # a shape on screen.
695 block = to_nodekey(request["path"])
696 self.computation.compute(block)
697 self._set_status(f"Computed {block}")
698 return
699 if self._view is None:
700 self._fail("Compute failed: the graph is not rendered")
701 return
702 visible = self._id_to_visible[request["id"]]
703 names = [member.name for member in self._view.original_nodes[visible]]
704 self.computation.compute(names)
705 self._set_status(f"Computed {self._full_visible_key(visible)}")
706 except Exception as exc:
707 # compute() validates before running and raises for uninitialized
708 # or placeholder ancestors; node failures land as ERROR states.
709 LOG.debug("Loman widget compute request failed", exc_info=True)
710 self._fail(f"Compute failed: {type(exc).__name__}: {exc}")
712 def _collapse_block(self, path: str) -> str | None:
713 """Close one open block, named by its path.
715 An expanded block is drawn as a cluster rather than a node, so the front
716 end identifies it by path rather than by a rendered node ID.
718 :param path: Path of the block to close.
719 :return: A success message, or ``None`` if that block was not open.
720 """
721 block = to_nodekey(path)
722 if block not in self._expanded:
723 return None
724 self._expanded.discard(block)
725 # Closing an outer block leaves anything expanded inside it unreachable,
726 # so those go too rather than lingering as invisible state.
727 for other in [nk for nk in self._expanded if nk.is_descendent_of(block)]:
728 self._expanded.discard(other)
729 return f"Closed {block}"
731 @traitlets.observe("toggle_request")
732 @_acknowledges
733 def _toggle_requested(self, change: dict[str, Any]) -> None:
734 """Open a collapsed block, close an open one, or collapse everything."""
735 request = change["new"]
736 if not request or not hasattr(self, "_id_to_visible") or not self._claim_request(request):
737 return
738 try:
739 if request.get("collapse_all"):
740 self._expanded.clear()
741 success = "Collapsed all blocks"
742 elif request.get("collapse"):
743 closed = self._collapse_block(request["path"])
744 if closed is None:
745 self._fail("Expand/collapse failed: that block is not open")
746 return
747 success = closed
748 elif self._view is None:
749 self._fail("Expand/collapse failed: the graph is not rendered")
750 return
751 else:
752 visible = self._id_to_visible[request["id"]]
753 if visible not in self._view.composite_nodes:
754 self._fail("Expand/collapse failed: only collapsed blocks can be expanded")
755 return
756 projected = len(self._view.node_index_map) - 1 + len(self._view.original_nodes[visible])
757 if projected > self._max_rendered_nodes:
758 self._fail(
759 f"Expand/collapse failed: opening this block would render about {projected} nodes, "
760 f"over the limit of {self._max_rendered_nodes}. "
761 f"Pass max_rendered_nodes= to comp.widget() to raise it."
762 )
763 return
764 block = self._full_visible_key(visible)
765 self._expanded.add(block)
766 success = f"Opened {block}"
767 if self._redraw():
768 self._set_status(success)
769 except Exception as exc:
770 LOG.debug("Loman widget expand/collapse request failed", exc_info=True)
771 self._fail(f"Expand/collapse failed: {type(exc).__name__}: {exc}")
773 @traitlets.observe("layout_request")
774 @_acknowledges
775 def _layout_requested(self, change: dict[str, Any]) -> None:
776 """Change the Graphviz layout direction, then relayout."""
777 request = change["new"]
778 if not request or not hasattr(self, "_id_to_visible") or not self._claim_request(request):
779 return
780 rankdir = str(request.get("rankdir", "")).upper()
781 if rankdir not in {"LR", "TB", "RL", "BT"}:
782 self._fail(f"Layout failed: {rankdir or '(empty)'} is not a valid rankdir")
783 return
784 previous = self._canonical_rankdir
785 self._canonical_rankdir = rankdir
786 with self._own_write():
787 self.rankdir = rankdir
788 if self._redraw():
789 self._set_status(f"Layout direction {rankdir}")
790 else:
791 # The relayout failed and left the old picture, so keep rankdir
792 # agreeing with what is on screen rather than what was asked for.
793 self._canonical_rankdir = previous
794 with self._own_write():
795 self.rankdir = previous
797 def _resolve_focus(self, request: dict[str, Any]) -> NodeKey | None:
798 """Resolve a focus request to the block it names, or the widget's root.
800 :param request: A ``{"path": ...}`` climbing the breadcrumb, or a
801 ``{"id": ...}`` descending into a rendered composite block.
802 :return: The block to focus on, or the widget's own root when reset.
803 :raises ValueError: If the request names somewhere outside the root.
804 :raises KeyError: If the rendered ID is unknown.
805 """
806 if "path" in request:
807 path = request["path"]
808 if not path:
809 return self._base_root if self._base_root is None else to_nodekey(self._base_root)
810 target = to_nodekey(path)
811 base = None if self._base_root is None else to_nodekey(self._base_root)
812 if base is not None and target != base and not target.is_descendent_of(base):
813 msg = f"{target} is not within this widget's root"
814 raise ValueError(msg)
815 return target
816 assert self._view is not None # noqa: S101
817 visible = self._id_to_visible[request["id"]]
818 if visible not in self._view.composite_nodes:
819 msg = "only blocks can be focused"
820 raise ValueError(msg)
821 return self._full_visible_key(visible)
823 @traitlets.observe("focus_request")
824 @_acknowledges
825 def _focus_requested(self, change: dict[str, Any]) -> None:
826 """Re-root the view onto one block, or back to the widget's own root.
828 Focusing drops every open expansion that is no longer under the new
829 root, since those blocks are no longer on screen to close.
830 """
831 request = change["new"]
832 if not request or not hasattr(self, "_id_to_visible") or not self._claim_request(request):
833 return
834 if self._view is None and "id" in request:
835 self._fail("Focus failed: the graph is not rendered")
836 return
837 try:
838 target = self._resolve_focus(request)
839 self._root = target
840 if target is None:
841 self._expanded.clear()
842 else:
843 root_nk = to_nodekey(target)
844 self._expanded = {nk for nk in self._expanded if nk.is_descendent_of(root_nk)}
845 if self._redraw():
846 self._set_status("Showing the whole graph" if target is None else f"Focused on {target}")
847 except Exception as exc:
848 LOG.debug("Loman widget focus request failed", exc_info=True)
849 self._fail(f"Focus failed: {type(exc).__name__}: {exc}")
851 @traitlets.observe("full_view_request")
852 @_acknowledges
853 def _full_view_requested(self, change: dict[str, Any]) -> None:
854 """Publish which node the user asked to see in full.
856 The widget deliberately does not render it. It only ever holds a window
857 onto a large value, and it cannot call the host's own renderers without
858 depending on that host. Naming the node here lets the notebook render it
859 with whatever it has --- ``mo.ui.table`` in marimo, a plain repr in
860 Jupyter --- which is the same division of labour as ``selected_name``.
861 """
862 request = change["new"]
863 if not request or not hasattr(self, "_id_to_visible") or not self._claim_request(request):
864 return
865 try:
866 if not request.get("id"):
867 self._set_full_view("")
868 self._set_status("Closed the full view")
869 return
870 if self._view is None:
871 self._fail("Show full failed: the graph is not rendered")
872 return
873 visible = self._id_to_visible[request["id"]]
874 members = self._view.original_nodes[visible]
875 if len(members) != 1:
876 self._fail("Show full failed: a collapsed block has no single value")
877 return
878 node_key = members[0]
879 self._set_full_view(str(node_key.name), node_key)
880 self._set_status(f"Showing {node_key.name} in full below")
881 except Exception as exc:
882 LOG.debug("Loman widget full-view request failed", exc_info=True)
883 self._fail(f"Show full failed: {type(exc).__name__}: {exc}")
885 def _set_full_view(self, name: str, node_key: NodeKey | None = None) -> None:
886 """Record the node whose full value the notebook should render.
888 Both the label and the key are kept. The trait is what the front end
889 and the notebook react to, and it has to be a string to be synced; the
890 key is what the value is actually fetched by, because ``str`` is lossy
891 over node names --- a node called ``1`` and a node called ``"1"`` are
892 different nodes with the same label.
893 """
894 self._canonical_full_view = name
895 self._full_view_key = node_key
896 with self._own_write():
897 self.full_view = name
899 @property
900 def full_view_name(self) -> Name | None:
901 """Return the real Loman name behind :attr:`full_view`, or ``None``.
903 :attr:`full_view` is the display label; this is the name itself, with
904 its original type, in the same way :attr:`selected_name` is.
905 """
906 return None if self._full_view_key is None else self._full_view_key.name
908 @property
909 def full_view_value(self) -> Any:
910 """Return the value behind :attr:`full_view`, or ``None`` if unset.
912 Convenience for the common notebook cell, which would otherwise have to
913 guard the empty case before indexing the computation.
914 """
915 if self._full_view_key is None:
916 return None
917 return self.computation.value(self._full_view_key)
919 def _request_key(self, request: dict[str, Any]) -> NodeKey:
920 """Resolve which existing node a graph request is aimed at.
922 :param request: A request naming a rendered node by ``id``, or a node
923 by ``target`` in the relative form the node form uses.
924 :return: The full computation key of the node.
925 :raises GraphBuildError: If the ID names a collapsed block, which is
926 several nodes rather than one.
927 :raises KeyError: If the rendered ID is unknown.
928 """
929 if "id" not in request:
930 return resolve_name(str(request.get("target", "")), self._root)
931 assert self._view is not None # noqa: S101
932 visible = self._id_to_visible[request["id"]]
933 members = self._view.original_nodes[visible]
934 if len(members) != 1:
935 msg = f"{self._full_visible_key(visible)} is a block; open it and act on the nodes inside"
936 raise GraphBuildError(msg)
937 return members[0]
939 def _select_key(self, node_key: NodeKey) -> None:
940 """Select a node by its computation key, if it is on screen.
942 Selecting what was just built is what makes the node form feel like it
943 put something somewhere: the panel opens on the new node, showing the
944 state it landed in and the definition it was given.
945 """
946 if self._view is None:
947 return
948 visible = node_key if self._root is None else node_key.drop_root(to_nodekey(self._root))
949 node_id = None if visible is None else self._view.node_index_map.get(visible)
950 if node_id is not None:
951 self.selected_id = node_id
953 def _add_node(self, request: dict[str, Any]) -> str:
954 """Add a node, or replace one the browser asked to redefine.
956 A PLACEHOLDER does not count as existing. It is the outline Loman
957 leaves where a node was referred to but never defined, so defining it
958 is filling in a blank rather than overwriting anything --- and it is
959 the very next thing to do after building a node whose inputs are not
960 there yet.
961 """
962 definition = build_definition(request, root=self._root, namespace=self._namespace)
963 existed = (
964 self.computation.has_node(definition.key) and self.computation.state(definition.key) != States.PLACEHOLDER
965 )
966 if existed and not request.get("replace"):
967 msg = f"{definition.key} already exists; select it and choose Edit to redefine it"
968 raise GraphBuildError(msg)
969 definition.apply(self.computation)
970 self._select_key(definition.key)
971 return f"{'Redefined' if existed else 'Added'} {definition.key}"
973 def _rename_node(self, request: dict[str, Any]) -> str:
974 """Rename an existing node, keeping the edges into and out of it."""
975 node_key = self._request_key(request)
976 new_key = resolve_name(str(request.get("name", "")), self._root)
977 if new_key == node_key:
978 msg = f"{node_key} already has that name"
979 raise GraphBuildError(msg)
980 self.computation.rename_node(node_key, new_key)
981 self._select_key(new_key)
982 return f"Renamed {node_key} to {new_key}"
984 def _delete_node(self, request: dict[str, Any]) -> str:
985 """Delete an existing node.
987 Loman keeps a deleted node that others still depend on as a
988 PLACEHOLDER, so the message says which happened rather than claiming
989 the node has gone when its outline is still on screen.
990 """
991 node_key = self._request_key(request)
992 self.computation.delete_node(node_key)
993 if self.computation.has_node(node_key):
994 return f"{node_key} is now a placeholder: nodes still depend on it"
995 return f"Deleted {node_key}"
997 @traitlets.observe("graph_request")
998 @_acknowledges
999 def _graph_requested(self, change: dict[str, Any]) -> None:
1000 """Build the graph itself: add, redefine, rename or delete a node.
1002 This is the one request that runs code the browser wrote, so it is
1003 gated on :attr:`buildable` as well as :attr:`editable`, and says
1004 which switch is missing rather than refusing silently.
1005 """
1006 request = change["new"]
1007 if not request or not hasattr(self, "_id_to_visible") or not self._claim_request(request):
1008 return
1009 if not self.editable:
1010 self._fail("Graph edit failed: this widget is read-only")
1011 return
1012 if not self.buildable:
1013 self._fail("Graph edit failed: pass buildable=True to comp.widget() to build the graph here")
1014 return
1015 handlers: dict[str, Callable[[dict[str, Any]], str]] = {
1016 "add": self._add_node,
1017 "rename": self._rename_node,
1018 "delete": self._delete_node,
1019 }
1020 handler = handlers.get(str(request.get("action")))
1021 if handler is None:
1022 self._fail(f"Graph edit failed: {request.get('action')!r} is not something the graph builder does")
1023 return
1024 try:
1025 self._set_status(handler(request))
1026 except (GraphBuildError, ValueWireError) as exc:
1027 # Written for whoever is looking at the form, so it is shown as
1028 # written rather than behind the name of its exception class.
1029 LOG.debug("Loman widget graph request was rejected", exc_info=True)
1030 self._fail(f"Graph edit failed: {exc}")
1031 except Exception as exc:
1032 # Everything else, from a name Loman will not take to a node that
1033 # cannot be deleted: the status line is the only place the person
1034 # who pressed the button would ever see it.
1035 LOG.debug("Loman widget graph request failed", exc_info=True)
1036 self._fail(f"Graph edit failed: {type(exc).__name__}: {exc}")
1038 def close(self) -> None:
1039 """Unsubscribe from the computation and close the widget comm."""
1040 if self._unsubscribe is not None:
1041 self._unsubscribe()
1042 self._unsubscribe = None
1043 super().close()