Coverage for src/CSET/graph.py: 100%
44 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 08:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 08:32 +0000
1# © Crown copyright, Met Office (2022-2024) and CSET contributors.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Visualise recipe into a graph."""
17import logging
18import subprocess
19import sys
20import tempfile
21from pathlib import Path
22from uuid import uuid4
24import pygraphviz
26from CSET._common import parse_recipe
28logger = logging.getLogger(__name__)
31def save_graph(
32 recipe_file: Path | str,
33 save_path: Path | None = None,
34 auto_open: bool = False,
35 detailed: bool = False,
36):
37 """
38 Draws out the graph of a recipe, and saves it to a file.
40 Parameters
41 ----------
42 recipe_file: Path | str
43 The recipe to be graphed.
45 save_path: Path
46 Path where to save the generated image. Defaults to a temporary file.
48 auto_open: bool
49 Whether to automatically open the graph with the default image viewer.
51 detailed: bool
52 Whether to include operator arguments on the graph.
54 Raises
55 ------
56 ValueError
57 Recipe is invalid.
58 """
59 recipe = parse_recipe(recipe_file)
60 if save_path is None:
61 save_path = Path(f"{tempfile.gettempdir()}/{uuid4()}.svg")
63 def step_parser(step: dict, prev_node: str) -> str:
64 """Parse recipe to add nodes to graph and link them with edges."""
65 logger.debug("Executing step: %s", step)
66 node = str(uuid4())
67 graph.add_node(node, label=step["operator"])
68 kwargs = {}
69 for key, value in step.items():
70 if isinstance(value, dict) and "operator" in value:
71 logger.debug("Recursing into argument: %s", key)
72 sub_node = step_parser(value, prev_node)
73 graph.add_edge(sub_node, node)
74 elif key != "operator":
75 kwargs[key] = value
76 graph.add_edge(prev_node, node)
78 if detailed:
79 graph.get_node(node).attr["label"] = f"{step['operator']}\n" + "".join(
80 f"<{key}: {kwargs[key]}>\n" for key in kwargs
81 )
82 return node
84 graph = pygraphviz.AGraph(directed=True)
86 prev_node = "START"
87 graph.add_node(prev_node)
88 try:
89 for step in recipe["steps"]:
90 prev_node = step_parser(step, prev_node)
91 except KeyError as err:
92 raise ValueError("Invalid recipe") from err
94 graph.draw(save_path, format="svg", prog="dot")
95 print(f"Graph rendered to {save_path}")
97 if auto_open:
98 try:
99 # Stderr is redirected here to suppress gvfs-open deprecation warning.
100 # See https://bugs.python.org/issue30219 for an example.
101 subprocess.run(
102 ("xdg-open", str(save_path)), check=True, stderr=subprocess.DEVNULL
103 )
104 except (subprocess.CalledProcessError, FileNotFoundError):
105 # Using print rather than logging as this is run interactively.
106 print(
107 "Cannot automatically display graph. Specify an output with -o instead.",
108 file=sys.stderr,
109 )