Coverage for src/loman/visualization.py: 100%

408 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-07 00:32 +0000

1"""Visualization tools for computation graphs using Graphviz.""" 

2 

3import os 

4import subprocess # nosec B404 

5import sys 

6import tempfile 

7from abc import ABC, abstractmethod 

8from collections import defaultdict 

9from collections.abc import Sequence 

10from dataclasses import dataclass, field 

11from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable 

12 

13import matplotlib as mpl 

14import networkx as nx 

15import numpy as np 

16import pandas as pd 

17import pydotplus 

18from matplotlib.colors import Colormap 

19 

20from .consts import NodeAttributes, NodeTransformations, States 

21from .graph_utils import contract_node 

22from .nodekey import Name, NodeKey, is_pattern, match_pattern, to_nodekey 

23 

24if TYPE_CHECKING: 

25 from .computeengine import Computation 

26 

27 

28@runtime_checkable 

29class _ComputationLike(Protocol): 

30 """Structural stand-in for :class:`~loman.computeengine.Computation`. 

31 

32 ``visualization`` is a lower layer than ``computeengine`` (which imports 

33 :class:`GraphView`/:class:`NodeFormatter` from here), so importing the 

34 top-level ``loman`` package — or ``computeengine`` — at module load would 

35 create an import cycle. A nested computation stored as a node value is 

36 instead detected structurally, by the attributes that identify a 

37 Computation, without importing anything from the upper layer. 

38 """ 

39 

40 dag: Any 

41 

42 def add_node(self, *args: Any, **kwargs: Any) -> Any: 

43 """Marker method present on any Computation.""" 

44 ... 

45 

46 def compute(self, *args: Any, **kwargs: Any) -> Any: 

47 """Marker method present on any Computation.""" 

48 ... 

49 

50 

51@dataclass 

52class Node: 

53 """Represents a node in the visualization graph.""" 

54 

55 nodekey: NodeKey 

56 original_nodekey: NodeKey 

57 data: dict[str, Any] 

58 

59 

60class NodeFormatter(ABC): 

61 """Abstract base class for node formatting in visualizations.""" 

62 

63 @abstractmethod 

64 def calibrate(self, nodes: list[Node]) -> None: 

65 """Calibrate formatter based on all nodes in the graph.""" 

66 # pragma: no cover 

67 

68 @abstractmethod 

69 def format(self, name: NodeKey, nodes: list[Node], is_composite: bool) -> dict[str, Any] | None: 

70 """Format node appearance returning dict of graphviz attributes.""" 

71 # pragma: no cover 

72 

73 @staticmethod 

74 def create( 

75 cmap: dict[States | None, str] | Colormap | None = None, colors: str = "state", shapes: str | None = None 

76 ) -> "CompositeNodeFormatter": 

77 """Create a composite node formatter with specified color and shape options.""" 

78 node_formatters: list[NodeFormatter] = [StandardLabel(), StandardGroup()] 

79 

80 if isinstance(shapes, str): 

81 shapes = shapes.lower() 

82 if shapes == "type": 

83 node_formatters.append(ShapeByType()) 

84 elif shapes is None: 

85 pass 

86 else: 

87 msg = f"{shapes} is not a valid loman shapes parameter for visualization" 

88 raise ValueError(msg) 

89 

90 colors = colors.lower() 

91 if colors == "state": 

92 state_cmap = cmap if isinstance(cmap, dict) else None 

93 node_formatters.append(ColorByState(state_cmap)) # type: ignore[arg-type] 

94 elif colors == "timing": 

95 timing_cmap = cmap if isinstance(cmap, Colormap) else None 

96 node_formatters.append(ColorByTiming(timing_cmap)) 

97 else: 

98 msg = f"{colors} is not a valid loman colors parameter for visualization" 

99 raise ValueError(msg) 

100 

101 node_formatters.append(StandardStylingOverrides()) 

102 node_formatters.append(RectBlocks()) 

103 

104 return CompositeNodeFormatter(node_formatters) 

105 

106 

107def aggregate_states(states: Sequence[States | None]) -> States | None: 

108 """Reduce the states of one rendered node's members to a single state. 

109 

110 A rendered node stands for either one computation node or, when a block is 

111 collapsed, all of its members. A single member reports its own state. 

112 Otherwise ERROR wins, then STALE, then the common state if every member 

113 agrees, and a genuine mixture reduces to ``None``. 

114 

115 ``None`` is Loman's mixed marker throughout: :class:`ColorByState` paints it 

116 white and the notebook widget labels it ``MIXED``. Both read it from here so 

117 that the rendered colour and the reported state cannot drift apart. 

118 

119 :param states: States of the members behind one rendered node. 

120 :return: The state to display, or ``None`` when the members disagree. 

121 """ 

122 if len(states) == 1: 

123 return states[0] 

124 if any(s == States.ERROR for s in states): 

125 return States.ERROR 

126 if any(s == States.STALE for s in states): 

127 return States.STALE 

128 first = states[0] if states else None 

129 return first if all(s == first for s in states) else None 

130 

131 

132class ColorByState(NodeFormatter): 

133 """Node formatter that colors nodes based on their computation state.""" 

134 

135 DEFAULT_STATE_COLORS: ClassVar[dict[States | None, str]] = { 

136 None: "#ffffff", # xkcd white 

137 States.PLACEHOLDER: "#f97306", # xkcd orange 

138 States.UNINITIALIZED: "#0343df", # xkcd blue 

139 States.STALE: "#ffff14", # xkcd yellow 

140 States.COMPUTABLE: "#9dff00", # xkcd bright yellow green 

141 States.UPTODATE: "#15b01a", # xkcd green 

142 States.ERROR: "#e50000", # xkcd red 

143 States.PINNED: "#bf77f6", # xkcd light purple 

144 } 

145 

146 def __init__(self, state_colors: dict[States | None, str] | None = None) -> None: 

147 """Initialize with custom state color mapping.""" 

148 if state_colors is None: 

149 state_colors = self.DEFAULT_STATE_COLORS.copy() 

150 self.state_colors = state_colors 

151 

152 def calibrate(self, nodes: list[Node]) -> None: 

153 """Calibrate formatter based on all nodes in the graph.""" 

154 

155 def format(self, name: NodeKey, nodes: list[Node], is_composite: bool) -> dict[str, Any] | None: 

156 """Format node color based on computation state.""" 

157 state = aggregate_states([node.data.get(NodeAttributes.STATE, None) for node in nodes]) 

158 return {"style": "filled", "fillcolor": self.state_colors[state]} 

159 

160 

161class ColorByTiming(NodeFormatter): 

162 """Node formatter that colors nodes based on their execution timing.""" 

163 

164 def __init__(self, cmap: Colormap | None = None) -> None: 

165 """Initialize with an optional colormap for timing visualization.""" 

166 if cmap is None: 

167 cmap = mpl.colors.LinearSegmentedColormap.from_list("blend", ["#15b01a", "#ffff14", "#e50000"]) 

168 self.cmap = cmap 

169 self.min_duration: float = float("nan") 

170 self.max_duration: float = float("nan") 

171 

172 def calibrate(self, nodes: list[Node]) -> None: 

173 """Calibrate the color mapping based on node timing data.""" 

174 durations: list[float] = [] 

175 for node in nodes: 

176 timing = node.data.get(NodeAttributes.TIMING) 

177 if timing is not None: 

178 durations.append(timing.duration) 

179 if durations: 

180 self.max_duration = max(durations) 

181 self.min_duration = min(durations) 

182 

183 def format(self, name: NodeKey, nodes: list[Node], is_composite: bool) -> dict[str, Any] | None: 

184 """Format a node with timing-based coloring.""" 

185 if len(nodes) == 1: 

186 data = nodes[0].data 

187 timing_data = data.get(NodeAttributes.TIMING) 

188 if timing_data is None: 

189 col = "#FFFFFF" 

190 else: 

191 duration = timing_data.duration 

192 norm_duration: float = (duration - self.min_duration) / max(1e-8, self.max_duration - self.min_duration) 

193 col = mpl.colors.rgb2hex(self.cmap(norm_duration)) 

194 return {"style": "filled", "fillcolor": col} 

195 return None 

196 

197 

198class ShapeByType(NodeFormatter): 

199 """Node formatter that sets node shapes based on their type.""" 

200 

201 def calibrate(self, nodes: list[Node]) -> None: 

202 """Calibrate formatter based on all nodes in the graph.""" 

203 

204 def format(self, name: NodeKey, nodes: list[Node], is_composite: bool) -> dict[str, Any] | None: 

205 """Format a node with type-based shape styling.""" 

206 if len(nodes) == 1: 

207 data = nodes[0].data 

208 value = data.get(NodeAttributes.VALUE) 

209 if value is None: 

210 return None 

211 if isinstance(value, np.ndarray): 

212 return {"shape": "rect"} 

213 elif isinstance(value, pd.DataFrame): 

214 return {"shape": "box3d"} 

215 elif np.isscalar(value): 

216 return {"shape": "ellipse"} 

217 elif isinstance(value, (list, tuple)): 

218 return {"shape": "ellipse", "peripheries": 2} 

219 elif isinstance(value, dict): 

220 return {"shape": "house", "peripheries": 2} 

221 elif isinstance(value, _ComputationLike): 

222 return {"shape": "hexagon"} 

223 else: 

224 return {"shape": "diamond"} 

225 return None 

226 

227 

228class RectBlocks(NodeFormatter): 

229 """Node formatter that shapes composite nodes as rectangles.""" 

230 

231 def calibrate(self, nodes: list[Node]) -> None: 

232 """Calibrate formatter based on all nodes in the graph.""" 

233 

234 def format(self, name: NodeKey, nodes: list[Node], is_composite: bool) -> dict[str, Any] | None: 

235 """Return rectangle shape for composite nodes.""" 

236 if is_composite: 

237 return {"shape": "rect", "peripheries": 2} 

238 return None 

239 

240 

241class StandardLabel(NodeFormatter): 

242 """Node formatter that sets node labels.""" 

243 

244 def calibrate(self, nodes: list[Node]) -> None: 

245 """Calibrate formatter based on all nodes in the graph.""" 

246 

247 def format(self, name: NodeKey, nodes: list[Node], is_composite: bool) -> dict[str, Any] | None: 

248 """Return standard label for node.""" 

249 return {"label": name.label} 

250 

251 

252def get_group_path(name: NodeKey, data: dict[str, Any]) -> NodeKey: 

253 """Determine the group path for a node based on name hierarchy and group attribute.""" 

254 name_group_path = name.parent 

255 attribute_group = data.get(NodeAttributes.GROUP) 

256 attribute_group_path = None if attribute_group is None else NodeKey((attribute_group,)) 

257 

258 group_path = name_group_path.join(attribute_group_path) 

259 return group_path 

260 

261 

262class StandardGroup(NodeFormatter): 

263 """Node formatter that applies standard grouping styles.""" 

264 

265 def calibrate(self, nodes: list[Node]) -> None: 

266 """Calibrate formatter based on all nodes in the graph.""" 

267 

268 def format(self, name: NodeKey, nodes: list[Node], is_composite: bool) -> dict[str, Any] | None: 

269 """Format a node with standard group styling.""" 

270 if len(nodes) == 1: 

271 data = nodes[0].data 

272 group_path = get_group_path(name, data) 

273 else: 

274 group_path = name.parent 

275 if group_path.is_root: 

276 return None 

277 return {"_group": group_path} 

278 

279 

280class StandardStylingOverrides(NodeFormatter): 

281 """Node formatter that applies standard styling overrides.""" 

282 

283 def calibrate(self, nodes: list[Node]) -> None: 

284 """Calibrate formatter based on all nodes in the graph.""" 

285 

286 def format(self, name: NodeKey, nodes: list[Node], is_composite: bool) -> dict[str, Any] | None: 

287 """Format a node with standard styling overrides.""" 

288 if len(nodes) == 1: 

289 data = nodes[0].data 

290 style = data.get(NodeAttributes.STYLE) 

291 if style is None: 

292 return None 

293 if style == "small": 

294 return {"width": 0.3, "height": 0.2, "fontsize": 8} 

295 elif style == "dot": 

296 return {"shape": "point", "width": 0.1, "peripheries": 1} 

297 return None 

298 

299 

300@dataclass 

301class CompositeNodeFormatter(NodeFormatter): 

302 """A node formatter that combines multiple formatters together.""" 

303 

304 formatters: list[NodeFormatter] = field(default_factory=list) 

305 

306 def calibrate(self, nodes: list[Node]) -> None: 

307 """Calibrate all the contained formatters with the given nodes.""" 

308 for formatter in self.formatters: 

309 formatter.calibrate(nodes) 

310 

311 def format(self, name: NodeKey, nodes: list[Node], is_composite: bool) -> dict[str, Any] | None: 

312 """Format a node by combining output from all contained formatters.""" 

313 d: dict[str, Any] = {} 

314 for formatter in self.formatters: 

315 format_attrs = formatter.format(name, nodes, is_composite) 

316 if format_attrs is not None: 

317 d.update(format_attrs) 

318 return d 

319 

320 

321@dataclass 

322class GraphView: 

323 """A view for visualizing computation graphs as graphical diagrams.""" 

324 

325 computation: "Computation" 

326 root: Name | None = None 

327 node_formatter: NodeFormatter | None = None 

328 node_transformations: dict[Name, str] | None = None 

329 collapse_all: bool = True 

330 

331 graph_attr: dict[str, Any] | None = None 

332 node_attr: dict[str, Any] | None = None 

333 edge_attr: dict[str, Any] | None = None 

334 

335 struct_dag: nx.DiGraph | None = None 

336 viz_dag: nx.DiGraph | None = None 

337 viz_dot: pydotplus.Dot | None = None 

338 original_nodes: defaultdict[NodeKey, list[NodeKey]] = field(default_factory=lambda: defaultdict(list), init=False) 

339 composite_nodes: set[NodeKey] = field(default_factory=set, init=False) 

340 node_index_map: dict[NodeKey, str] = field(default_factory=dict, init=False) 

341 

342 def __post_init__(self) -> None: 

343 """Initialize the graph view after dataclass construction.""" 

344 self.refresh() 

345 

346 @staticmethod 

347 def get_sub_block( 

348 dag: nx.DiGraph, root: Name | None, node_transformations: dict[NodeKey, str] 

349 ) -> tuple[nx.DiGraph, defaultdict[NodeKey, list[NodeKey]], set[NodeKey]]: 

350 """Extract a subgraph with node transformations for visualization.""" 

351 d_transform_to_nodes: defaultdict[str, list[NodeKey]] = defaultdict(list) 

352 for nk, transform in node_transformations.items(): 

353 d_transform_to_nodes[transform].append(nk) 

354 

355 dag_out: nx.DiGraph = nx.DiGraph() 

356 

357 d_original_to_mapped: dict[NodeKey, NodeKey] = {} 

358 s_collapsed: set[NodeKey] = set() 

359 

360 for nk_original in dag.nodes(): 

361 nk_mapped = nk_original.drop_root(root) 

362 if nk_mapped is None: 

363 continue 

364 nk_highest_collapse = nk_original 

365 is_collapsed = False 

366 for nk_collapse in d_transform_to_nodes[NodeTransformations.COLLAPSE]: 

367 if nk_highest_collapse.is_descendent_of(nk_collapse): 

368 nk_highest_collapse = nk_collapse 

369 is_collapsed = True 

370 nk_mapped = nk_highest_collapse.drop_root(root) 

371 if nk_mapped is None: # pragma: no cover 

372 continue 

373 d_original_to_mapped[nk_original] = nk_mapped 

374 if is_collapsed: 

375 s_collapsed.add(nk_mapped) 

376 

377 for nk_mapped in d_original_to_mapped.values(): 

378 dag_out.add_node(nk_mapped) 

379 

380 for nk_u, nk_v in dag.edges(): 

381 nk_mapped_u = d_original_to_mapped.get(nk_u) 

382 nk_mapped_v = d_original_to_mapped.get(nk_v) 

383 if nk_mapped_u is None or nk_mapped_v is None or nk_mapped_u == nk_mapped_v: 

384 continue 

385 dag_out.add_edge(nk_mapped_u, nk_mapped_v) 

386 

387 for nk in d_transform_to_nodes[NodeTransformations.CONTRACT]: 

388 contract_node(dag_out, d_original_to_mapped[nk]) 

389 del d_original_to_mapped[nk] 

390 

391 d_mapped_to_original: defaultdict[NodeKey, list[NodeKey]] = defaultdict(list) 

392 for nk_original, nk_mapped in d_original_to_mapped.items(): 

393 if nk_mapped in dag_out.nodes: 

394 d_mapped_to_original[nk_mapped].append(nk_original) 

395 

396 s_collapsed.intersection_update(dag_out.nodes) 

397 

398 return dag_out, d_mapped_to_original, s_collapsed 

399 

400 def _initialize_transforms(self) -> dict[NodeKey, str]: 

401 """Initialize node transformations for visualization.""" 

402 node_transformations: dict[NodeKey, str] = {} 

403 if self.collapse_all: 

404 self._apply_default_collapse_transforms(node_transformations) 

405 self._apply_custom_transforms(node_transformations) 

406 return node_transformations 

407 

408 def _apply_default_collapse_transforms(self, node_transformations: dict[NodeKey, str]) -> None: 

409 """Apply default collapse transformations to tree nodes.""" 

410 for n in self.computation.get_tree_descendents(self.root): 

411 nk = to_nodekey(n) 

412 if not self.computation.has_node(nk): 

413 node_transformations[nk] = NodeTransformations.COLLAPSE 

414 

415 def _apply_custom_transforms(self, node_transformations: dict[NodeKey, str]) -> dict[NodeKey, str]: 

416 """Apply user-specified custom transformations to nodes.""" 

417 if self.node_transformations is not None: 

418 for rule_name, transform in self.node_transformations.items(): 

419 include_ancestors = transform == NodeTransformations.EXPAND 

420 rule_nk = to_nodekey(rule_name) 

421 if is_pattern(rule_nk): 

422 apply_nodes: set[NodeKey] = set() 

423 for n in self.computation.get_tree_descendents(self.root): 

424 nk = to_nodekey(n) 

425 if match_pattern(rule_nk, nk): 

426 apply_nodes.add(nk) 

427 else: 

428 apply_nodes = {rule_nk} 

429 node_transformations[rule_nk] = transform 

430 if include_ancestors: 

431 for nk in apply_nodes: 

432 for nk1 in nk.ancestors(): 

433 if nk1.is_root or nk1 == self.root: 

434 break 

435 node_transformations[nk1] = NodeTransformations.EXPAND 

436 for r_nk in apply_nodes: 

437 node_transformations[r_nk] = transform 

438 return node_transformations 

439 

440 def _create_visualization_dag( 

441 self, 

442 original_nodes: defaultdict[NodeKey, list[NodeKey]], 

443 composite_nodes: set[NodeKey], 

444 node_index_map: dict[NodeKey, str], 

445 ) -> nx.DiGraph: 

446 """Create the visualization DAG from structure and node data.""" 

447 node_formatter = self.node_formatter 

448 if node_formatter is None: 

449 node_formatter = NodeFormatter.create() 

450 assert self.struct_dag is not None # noqa: S101 

451 return create_viz_dag( 

452 self.struct_dag, 

453 self.computation.dag, 

454 node_formatter, 

455 original_nodes, 

456 composite_nodes, 

457 node_index_map=node_index_map, 

458 ) 

459 

460 def _create_dot_graph(self) -> pydotplus.Dot: 

461 """Create a PyDot graph from the visualization DAG.""" 

462 return to_pydot(self.viz_dag, self.graph_attr, self.node_attr, self.edge_attr) 

463 

464 def refresh(self) -> None: 

465 """Refresh the visualization by rebuilding the graph structure.""" 

466 node_transformations = self._initialize_transforms() 

467 self.struct_dag, self.original_nodes, self.composite_nodes = self.get_sub_block( 

468 self.computation.dag, self.root, node_transformations 

469 ) 

470 self.node_index_map = {} 

471 self.viz_dag = self._create_visualization_dag(self.original_nodes, self.composite_nodes, self.node_index_map) 

472 self.viz_dot = self._create_dot_graph() 

473 

474 def svg(self) -> str | None: 

475 """Generate SVG representation of the visualization.""" 

476 if self.viz_dot is None: 

477 return None 

478 svg_bytes: bytes = self.viz_dot.create_svg() # type: ignore[attr-defined] 

479 return svg_bytes.decode("utf-8") 

480 

481 def view(self) -> None: # pragma: no cover 

482 """Open the visualization in a PDF viewer.""" 

483 assert self.viz_dot is not None # noqa: S101 

484 with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f: 

485 f.write(self.viz_dot.create_pdf()) # type: ignore[attr-defined] 

486 if sys.platform == "win32": 

487 os.startfile(f.name) # pragma: no cover # nosec B606 # noqa: S606 

488 else: 

489 subprocess.run(["open", f.name], check=False) # pragma: no cover # nosec B603 B607 # noqa: S603, S607 

490 

491 def _repr_svg_(self) -> str | None: 

492 """Return SVG representation for Jupyter notebook display.""" 

493 return self.svg() 

494 

495 

496def create_viz_dag( 

497 struct_dag: nx.DiGraph, 

498 comp_dag: nx.DiGraph, 

499 node_formatter: NodeFormatter, 

500 original_nodes: defaultdict[NodeKey, list[NodeKey]], 

501 composite_nodes: set[NodeKey], 

502 node_index_map: dict[NodeKey, str] | None = None, 

503) -> nx.DiGraph: 

504 """Create a visualization DAG from the computation structure. 

505 

506 :param struct_dag: Structural graph of the nodes actually being rendered. 

507 :param comp_dag: The full computation graph, for node data. 

508 :param node_formatter: Formatter supplying each node's Graphviz attributes. 

509 :param original_nodes: Members standing behind each rendered node. 

510 :param composite_nodes: Rendered nodes that stand for a collapsed block. 

511 :param node_index_map: Optional dict to fill in with the node-key to 

512 rendered-name mapping. The mapping is assigned by enumeration order and 

513 cannot be reconstructed afterwards, so callers that need node identity 

514 --- the notebook widget, via :attr:`GraphView.node_index_map` --- pass a 

515 dict in rather than duplicating the numbering rule. 

516 :return: The visualization DAG, ready to convert to Graphviz. 

517 """ 

518 if node_formatter is not None: 

519 nodes: list[Node] = [] 

520 for nodekey in struct_dag.nodes: 

521 for original_nodekey in original_nodes[nodekey]: 

522 data = comp_dag.nodes[original_nodekey] 

523 n = Node(nodekey, original_nodekey, data) 

524 nodes.append(n) 

525 node_formatter.calibrate(nodes) 

526 

527 viz_dag: nx.DiGraph = nx.DiGraph() 

528 if node_index_map is None: 

529 node_index_map = {} 

530 for i, nodekey in enumerate(struct_dag.nodes): 

531 short_name = f"n{i}" 

532 attr_dict: dict[str, Any] | None = None 

533 

534 if node_formatter is not None: 

535 nodes = [] 

536 for original_nodekey in original_nodes[nodekey]: 

537 data = comp_dag.nodes[original_nodekey] 

538 n = Node(nodekey, original_nodekey, data) 

539 nodes.append(n) 

540 is_composite = nodekey in composite_nodes 

541 attr_dict = node_formatter.format(nodekey, nodes, is_composite) 

542 if attr_dict is None: # pragma: no cover 

543 attr_dict = {} 

544 

545 attr_dict = {k: v for k, v in attr_dict.items() if v is not None} 

546 

547 viz_dag.add_node(short_name, **attr_dict) 

548 node_index_map[nodekey] = short_name 

549 

550 for name1, name2 in struct_dag.edges(): 

551 short_name_1 = node_index_map[name1] 

552 short_name_2 = node_index_map[name2] 

553 

554 group_path1 = get_group_path(name1, struct_dag.nodes[name1]) 

555 group_path2 = get_group_path(name2, struct_dag.nodes[name2]) 

556 group_path = NodeKey.common_parent(group_path1, group_path2) 

557 

558 edge_attr_dict: dict[str, Any] = {} 

559 if not group_path.is_root: 

560 # group_path = None 

561 edge_attr_dict["_group"] = group_path 

562 

563 viz_dag.add_edge(short_name_1, short_name_2, **edge_attr_dict) 

564 

565 return viz_dag 

566 

567 

568def _group_nodes_and_edges( 

569 viz_dag: nx.DiGraph, 

570) -> tuple[NodeKey, dict[NodeKey, list[str]], dict[NodeKey, list[tuple[str, str]]]]: 

571 """Group nodes and edges by their groups.""" 

572 root = NodeKey.root() 

573 

574 node_groups: dict[NodeKey, list[str]] = {} 

575 for name, data in viz_dag.nodes(data=True): 

576 group = data.get("_group", root) 

577 node_groups.setdefault(group, []).append(name) 

578 

579 edge_groups: dict[NodeKey, list[tuple[str, str]]] = {} 

580 for name1, name2, data in viz_dag.edges(data=True): 

581 group = data.get("_group", root) 

582 edge_groups.setdefault(group, []).append((name1, name2)) 

583 

584 return root, node_groups, edge_groups 

585 

586 

587def _create_pydot_nodes( 

588 viz_dag: nx.DiGraph, 

589 node_groups: dict[NodeKey, list[str]], 

590 subgraphs: dict[NodeKey, pydotplus.Dot | pydotplus.Subgraph], 

591 root: NodeKey, 

592) -> None: 

593 """Create PyDot nodes for each group.""" 

594 for group, names in node_groups.items(): 

595 c = subgraphs[root] if group is root else create_subgraph(group) 

596 

597 for name in names: 

598 node = pydotplus.Node(name) 

599 for k, v in viz_dag.nodes[name].items(): 

600 if not k.startswith("_"): 

601 node.set(k, v) 

602 c.add_node(node) 

603 

604 subgraphs[group] = c 

605 

606 

607def _ensure_parent_subgraphs(subgraphs: dict[NodeKey, pydotplus.Dot | pydotplus.Subgraph]) -> None: 

608 """Ensure all parent subgraphs exist in the hierarchy.""" 

609 groups = list(subgraphs.keys()) 

610 for group in groups: 

611 group1 = group 

612 while True: 

613 if group1.is_root: 

614 break 

615 group1 = group1.parent 

616 if group1 in subgraphs: 

617 break 

618 subgraphs[group1] = create_subgraph(group1) 

619 

620 

621def _link_subgraphs(subgraphs: dict[NodeKey, pydotplus.Dot | pydotplus.Subgraph]) -> None: 

622 """Link subgraphs to their parents.""" 

623 for group, subgraph in subgraphs.items(): 

624 if group.is_root: 

625 continue 

626 parent = group 

627 while True: 

628 parent = parent.parent 

629 if parent in subgraphs or parent.is_root: 

630 break 

631 subgraphs[parent].add_subgraph(subgraph) 

632 

633 

634def _add_edges_to_subgraphs( 

635 edge_groups: dict[NodeKey, list[tuple[str, str]]], subgraphs: dict[NodeKey, pydotplus.Dot | pydotplus.Subgraph] 

636) -> None: 

637 """Add edges to their respective subgraphs.""" 

638 for group, edges in edge_groups.items(): 

639 c = subgraphs[group] 

640 for name1, name2 in edges: 

641 edge = pydotplus.Edge(name1, name2) 

642 c.add_edge(edge) 

643 

644 

645def to_pydot( 

646 viz_dag: nx.DiGraph | None, 

647 graph_attr: dict[str, Any] | None = None, 

648 node_attr: dict[str, Any] | None = None, 

649 edge_attr: dict[str, Any] | None = None, 

650) -> pydotplus.Dot: 

651 """Convert a visualization DAG to a PyDot graph for rendering.""" 

652 assert viz_dag is not None # noqa: S101 

653 root, node_groups, edge_groups = _group_nodes_and_edges(viz_dag) 

654 

655 subgraphs: dict[NodeKey, pydotplus.Dot | pydotplus.Subgraph] = { 

656 root: create_root_graph(graph_attr, node_attr, edge_attr) 

657 } 

658 

659 _create_pydot_nodes(viz_dag, node_groups, subgraphs, root) 

660 _ensure_parent_subgraphs(subgraphs) 

661 _link_subgraphs(subgraphs) 

662 _add_edges_to_subgraphs(edge_groups, subgraphs) 

663 

664 result = subgraphs[root] 

665 assert isinstance(result, pydotplus.Dot) # noqa: S101 

666 return result 

667 

668 

669def create_root_graph( 

670 graph_attr: dict[str, Any] | None, node_attr: dict[str, Any] | None, edge_attr: dict[str, Any] | None 

671) -> pydotplus.Dot: 

672 """Create root Graphviz graph with specified attributes. 

673 

674 Notes: 

675 Graphviz attributes like size expect a quoted string when containing 

676 commas (e.g. "10,8"). Some pydotplus setters don't auto-quote, which 

677 can produce a DOT syntax error near ',' if we pass a raw string. 

678 We defensively quote string values that contain commas or whitespace. 

679 """ 

680 

681 def _normalize_attr_value(v: Any) -> Any: 

682 """Normalize attribute values for Graphviz, quoting strings as needed.""" 

683 # Keep numeric values as-is 

684 if isinstance(v, (int, float)): 

685 return v 

686 s = str(v) 

687 # If already quoted, keep 

688 if len(s) >= 2 and ((s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'")): 

689 return s 

690 # Quote if contains comma, whitespace, or special characters 

691 if any(c in s for c in [",", " ", "\t", "\n"]) or s == "": 

692 return f'"{s}"' 

693 return s 

694 

695 root_graph = pydotplus.Dot() 

696 if graph_attr is not None: 

697 for k, v in graph_attr.items(): 

698 root_graph.set(k, _normalize_attr_value(v)) 

699 if node_attr is not None: 

700 # For node/edge defaults, normalize each value too 

701 node_defaults = {k: _normalize_attr_value(v) for k, v in node_attr.items()} 

702 root_graph.set_node_defaults(**node_defaults) 

703 if edge_attr is not None: 

704 edge_defaults = {k: _normalize_attr_value(v) for k, v in edge_attr.items()} 

705 root_graph.set_edge_defaults(**edge_defaults) 

706 return root_graph 

707 

708 

709def create_subgraph(group: NodeKey) -> pydotplus.Subgraph: 

710 """Create a Graphviz subgraph for a node group.""" 

711 c = pydotplus.Subgraph("cluster_" + str(group)) 

712 c.obj_dict["attributes"]["label"] = str(group) 

713 return c