# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.
"""Provides Creation and saving of DOT graphs for a :class:`iris.cube.Cube`."""
import os
import subprocess
import iris
from iris._deprecation import warn_deprecated
import iris.util
wmsg = (
"iris.fileformats.dot has been deprecated and will be removed in a "
"future release. If you make use of this functionality, please contact "
"the Iris Developers to discuss how to retain it (which may involve "
"reversing the deprecation)."
)
warn_deprecated(wmsg)
_GRAPH_INDENT = " " * 4
_SUBGRAPH_INDENT = " " * 8
_DOT_CHECKED = False
_DOT_EXECUTABLE_PATH = None
def _dot_path():
global _DOT_CHECKED, _DOT_EXECUTABLE_PATH
if _DOT_CHECKED:
path = _DOT_EXECUTABLE_PATH
else:
path = iris.config.get_option("System", "dot_path", default="dot")
if not os.path.exists(path):
if not os.path.isabs(path):
try:
# Check PATH
subprocess.check_output([path, "-V"], stderr=subprocess.STDOUT)
except (OSError, subprocess.CalledProcessError):
path = None
else:
path = None
_DOT_EXECUTABLE_PATH = path
_DOT_CHECKED = True
return path
#: Whether the 'dot' program is present (required for "dotpng" output).
DOT_AVAILABLE = _dot_path() is not None
[docs]
def save(cube, target):
"""Save a dot representation of the cube.
Parameters
----------
cube : :class:`iris.cube.Cube`
target :
A filename or open file handle.
See Also
--------
iris.io.save :
Save one or more Cubes to file (or other writeable).
"""
if isinstance(target, str):
dot_file = open(target, "wt")
elif hasattr(target, "write"):
if hasattr(target, "mode") and "b" in target.mode:
raise ValueError("Target is binary")
dot_file = target
else:
raise ValueError("Can only save dot to filename or filehandle")
try:
dot_file.write(cube_text(cube))
finally:
if isinstance(target, str):
dot_file.close()
[docs]
def save_png(source, target, launch=False):
"""Produce a "dot" instance diagram by calling dot.
Produce a "dot" instance diagram by calling dot and optionally launching
the resulting image.
Parameters
----------
source : :class:`iris.cube.Cube`, or dot filename
target :
A filename or open file handle.
If passing a file handle, take care to open it for binary output.
launch : bool, default=False
Display the image. Default is False.
See Also
--------
iris.io.save :
Save one or more Cubes to file (or other writeable).
"""
# From cube or dot file?
if isinstance(source, iris.cube.Cube):
# Create dot file
dot_file_path = iris.util.create_temp_filename(".dot")
save(source, dot_file_path)
elif isinstance(source, str):
dot_file_path = source
else:
raise ValueError("Can only write dot png for a Cube or DOT file")
# Create png data
if not _dot_path():
raise ValueError(
'Executable "dot" not found: Review dot_path setting in site.cfg.'
)
# To filename or open file handle?
if isinstance(target, str):
subprocess.call([_dot_path(), "-T", "png", "-o", target, dot_file_path])
elif hasattr(target, "write"):
if hasattr(target, "mode") and "b" not in target.mode:
raise ValueError("Target not binary")
subprocess.call([_dot_path(), "-T", "png", dot_file_path], stdout=target)
else:
raise ValueError("Can only write dot png for a filename or writable")
# Display?
if launch:
if os.name == "mac":
subprocess.call(("open", target))
elif os.name == "nt":
subprocess.call(("start", target))
elif os.name == "posix":
subprocess.call(("firefox", target))
else:
raise iris.exceptions.NotYetImplementedError(
"Unhandled operating system. The image has been created in %s" % target
)
# Remove the dot file if we created it
if isinstance(source, iris.cube.Cube):
os.remove(dot_file_path)
[docs]
def cube_text(cube):
"""Return a DOT text representation a `iris.cube.Cube`.
Parameters
----------
cube :
The cube for which to create DOT text.
"""
# We use r'' type string constructor as when we type \n in a string without the r'' constructor
# we get back a new line character - this is not what DOT expects.
# Therefore, newline characters should be created explicitly by having multi-lined strings.
relationships = r""
relationships_association = r""
dimension_nodes = r"""
subgraph clusterCubeDimensions {
label="Cube data"
"""
# TODO: Separate dim_coords from aux_coords.
coord_nodes = r"""
subgraph clusterCoords {
label = "Coords"
"""
coord_system_nodes = r"""
subgraph clusterCoordSystems {
label = "CoordSystems"
"""
for i, size in enumerate(cube.shape):
dimension_nodes += "\n" + _dot_node(
_SUBGRAPH_INDENT,
"CubeDimension_" + str(i),
str(i),
[("len", size)],
)
# Coords and their coord_systems
coords = sorted(cube.coords(), key=lambda c: c.name())
written_cs = []
for i, coord in enumerate(coords):
coord_label = "Coord_" + str(i)
coord_nodes += _coord_text(coord_label, coord)
cs = coord.coord_system
if cs:
# Create the cs node - or find an identical, already written cs
if cs not in written_cs:
written_cs.append(cs)
uid = written_cs.index(cs)
coord_system_nodes += _coord_system_text(cs, uid)
else:
uid = written_cs.index(cs)
relationships += '\n "%s" -> "CoordSystem_%s_%s"' % (
coord_label,
coord.coord_system.__class__.__name__,
uid,
)
relationships += '\n ":Cube" -> "%s"' % coord_label
# Are there any relationships to data dimensions?
dims = cube.coord_dims(coord)
for dim in dims:
relationships_association += '\n "%s" -> "CubeDimension_%s":w' % (
coord_label,
dim,
)
dimension_nodes += """
}
"""
coord_nodes += """
}
"""
coord_system_nodes += """
}
"""
# return a string pulling everything together
template = """
digraph CubeGraph{
rankdir = "LR"
fontname = "Bitstream Vera Sans"
fontsize = 8
node [
fontname = "Bitstream Vera Sans"
fontsize = 8
shape = "record"
]
# Nodes
%(cube_node)s
%(dimension_nodes)s
%(coord_nodes)s
%(coord_sys_nodes)s
edge [
arrowhead = "normal"
]
# RELATIONSHIPS
# Containment
%(relationships)s
edge [
style="dashed"
arrowhead = "onormal"
]
# Association
%(associations)s
}
"""
cube_attributes = list(sorted(cube.attributes.items(), key=lambda item: item[0]))
cube_node = _dot_node(_GRAPH_INDENT, ":Cube", "Cube", cube_attributes)
res_string = template % {
"cube_node": cube_node,
"dimension_nodes": dimension_nodes,
"coord_nodes": coord_nodes,
"coord_sys_nodes": coord_system_nodes,
"relationships": relationships,
"associations": relationships_association,
}
return res_string
def _coord_text(label, coord):
"""Return a string containing the dot representation for a single coordinate node.
Parameters
----------
label :
The dot ID of the coordinate node.
coord :
The coordinate to convert.
"""
# Which bits to write?
# Note: This is is not very OO but we are achieving a separation of DOT from cdm by doing this.
if isinstance(coord, iris.coords.DimCoord):
_dot_attrs = ("standard_name", "long_name", "units", "circular")
elif isinstance(coord, iris.coords.AuxCoord):
_dot_attrs = ("standard_name", "long_name", "units")
else:
raise ValueError("Unhandled coordinate type: " + str(type(coord)))
attrs = [(name, getattr(coord, name)) for name in _dot_attrs]
if coord.attributes:
custom_attrs = sorted(coord.attributes.items(), key=lambda item: item[0])
attrs.extend(custom_attrs)
node = _dot_node(_SUBGRAPH_INDENT, label, coord.__class__.__name__, attrs)
return node
def _coord_system_text(cs, uid):
"""Return string containing dot representation for a single coordinate system node.
Parameters
----------
cs :
The coordinate system to convert.
uid :
The uid allows/distinguishes non-identical CoordSystems of the same
type.
"""
attrs = []
for k, v in cs.__dict__.items():
if isinstance(v, iris.cube.Cube):
attrs.append((k, "defined"))
else:
attrs.append((k, v))
attrs.sort(key=lambda attr: attr[0])
label = "CoordSystem_%s_%s" % (cs.__class__.__name__, uid)
node = _dot_node(_SUBGRAPH_INDENT, label, cs.__class__.__name__, attrs)
return node
def _dot_node(indent, id, name, attributes):
"""Return a string containing the dot representation for a single node.
Parameters
----------
id :
The ID of the node.
name :
The visual name of the node.
attributes :
An iterable of (name, value) attribute pairs.
""" # noqa: D410, D411
attributes = r"\n".join("%s: %s" % item for item in attributes)
template = """%(indent)s"%(id)s" [
%(indent)s label = "%(name)s|%(attributes)s"
%(indent)s]
"""
node = template % {
"id": id,
"name": name,
"attributes": attributes,
"indent": indent,
}
return node