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

1"""AnyWidget implementation for live Loman computation graphs.""" 

2 

3from __future__ import annotations 

4 

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 

12 

13import anywidget 

14import traitlets 

15 

16from loman.computeengine import ComputationEvent 

17from loman.consts import NodeTransformations, States 

18from loman.nodekey import Name, NodeKey, to_nodekey 

19 

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 

23 

24if TYPE_CHECKING: 

25 from collections.abc import Iterator 

26 

27 from loman.computeengine import Computation 

28 from loman.visualization import GraphView 

29 

30LOG = logging.getLogger("loman.ui.widget") 

31 

32_STATIC = Path(__file__).parent / "static" 

33 

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 

40 

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 

49 

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 

55 

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() 

59 

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 

72 

73 

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.""" 

76 

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() 

84 

85 return wrapped 

86 

87 

88class _DrawOptions(TypedDict): 

89 """Keyword options forwarded to :meth:`Computation.draw`.""" 

90 

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 

99 

100 

101class ComputationWidget(anywidget.AnyWidget): 

102 """Interactive graph view that automatically follows a computation. 

103 

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. 

107 

108 Two costs are worth knowing about: 

109 

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. 

117 

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 """ 

124 

125 _esm = _STATIC / "widget.js" 

126 _css = _STATIC / "widget.css" 

127 

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) 

182 

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) 

190 

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``. 

213 

214 Arguments other than ``editable``, ``buildable``, ``namespace`` 

215 and ``max_rendered_nodes`` mirror :meth:`Computation.draw`. 

216 

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) 

283 

284 @property 

285 def selected_names(self) -> list[Name]: 

286 """Return the real Loman names represented by the selected shape. 

287 

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]] 

297 

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 

306 

307 @property 

308 def selected(self) -> Name | None: 

309 """Alias for :attr:`selected_name`.""" 

310 return self.selected_name 

311 

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) 

315 

316 def _focus_trail(self) -> list[dict[str, str]]: 

317 """Describe the path from the widget's own root to the block in focus. 

318 

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 

335 

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 ) 

354 

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 

363 

364 def _set_status(self, text: str, severity: str = "success") -> None: 

365 """Set the status line and record it as Python's canonical value. 

366 

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. 

373 

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 

384 

385 def _fail(self, text: str) -> None: 

386 """Report a failed request on the status line.""" 

387 self._set_status(text, "error") 

388 

389 def _acknowledge(self) -> None: 

390 """Tell the browser a request has been dealt with. 

391 

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 

398 

399 def _layout_key(self) -> tuple[Any, ...] | None: 

400 """Return what the layout about to be drawn depends on, or ``None``. 

401 

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 ) 

415 

416 def _layout(self) -> tuple[GraphView, str]: 

417 """Lay the current view out, reusing a stored one where that is sound. 

418 

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 

434 

435 def refresh(self) -> bool: 

436 """Force a full graph refresh, including SVG layout and node identity. 

437 

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. 

442 

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() 

448 

449 def _redraw(self) -> bool: 

450 """Re-render from the current root and expansions, reusing layouts. 

451 

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 

492 

493 def _node_names(self) -> list[str]: 

494 """Name every node in the computation, for the node form's suggestions. 

495 

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) 

503 

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 ) 

515 

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) 

520 

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() 

532 

533 def _claim_request(self, request: dict[str, Any]) -> bool: 

534 """Report whether a browser request is new rather than a replay. 

535 

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 

549 

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() 

555 

556 def _canonical_output(self, name: str) -> Any: 

557 """Return the value trait ``name`` should hold. 

558 

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. 

563 

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]() 

590 

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. 

608 

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) 

624 

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. 

629 

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}") 

675 

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}") 

711 

712 def _collapse_block(self, path: str) -> str | None: 

713 """Close one open block, named by its path. 

714 

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. 

717 

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}" 

730 

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}") 

772 

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 

796 

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. 

799 

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) 

822 

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. 

827 

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}") 

850 

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. 

855 

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}") 

884 

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. 

887 

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 

898 

899 @property 

900 def full_view_name(self) -> Name | None: 

901 """Return the real Loman name behind :attr:`full_view`, or ``None``. 

902 

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 

907 

908 @property 

909 def full_view_value(self) -> Any: 

910 """Return the value behind :attr:`full_view`, or ``None`` if unset. 

911 

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) 

918 

919 def _request_key(self, request: dict[str, Any]) -> NodeKey: 

920 """Resolve which existing node a graph request is aimed at. 

921 

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] 

938 

939 def _select_key(self, node_key: NodeKey) -> None: 

940 """Select a node by its computation key, if it is on screen. 

941 

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 

952 

953 def _add_node(self, request: dict[str, Any]) -> str: 

954 """Add a node, or replace one the browser asked to redefine. 

955 

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}" 

972 

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}" 

983 

984 def _delete_node(self, request: dict[str, Any]) -> str: 

985 """Delete an existing node. 

986 

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}" 

996 

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. 

1001 

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}") 

1037 

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()