diff --git a/MANIFEST.in b/MANIFEST.in index 69be0e85811d..12b63c6cf375 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,6 +3,7 @@ include requirements.txt recursive-include qiskit/qasm/libs *.inc include qiskit/VERSION.txt include qiskit/visualization/circuit/styles/*.json +include qiskit/visualization/dag/styles/*.json recursive-include qiskit/providers/fake_provider/backends_v1 *.json # Include the tests files. diff --git a/qiskit/visualization/circuit/qcstyle.py b/qiskit/visualization/circuit/qcstyle.py index 67ae9faaf24b..3407aa97afab 100644 --- a/qiskit/visualization/circuit/qcstyle.py +++ b/qiskit/visualization/circuit/qcstyle.py @@ -161,7 +161,14 @@ def __init__(self): self.style = StyleDict(**default_style) -def load_style(style: dict | str | None) -> tuple[StyleDict, float]: +def load_style( + style: dict | str | None, + style_dict=StyleDict, + default_style=DefaultStyle, + default_style_name="default", + user_config_opt="circuit_mpl_style", + user_config_path_opt="circuit_mpl_style_path", +) -> tuple[StyleDict, float]: """Utility function to load style from json files. Args: @@ -187,14 +194,14 @@ def load_style(style: dict | str | None) -> tuple[StyleDict, float]: config = user_config.get_config() if style is None: if config: - style = config.get("circuit_mpl_style", "default") + style = config.get(user_config_opt, default_style_name) else: - style = "default" + style = default_style_name # determine the style name which could also be inside a dictionary, like # style={"name": "clifford", } if isinstance(style, dict): - style_name = style.get("name", "default") + style_name = style.get("name", default_style_name) elif isinstance(style, str): if style.endswith(".json"): style_name = style[:-5] @@ -207,10 +214,10 @@ def load_style(style: dict | str | None) -> tuple[StyleDict, float]: UserWarning, 2, ) - style_name = "default" + style_name = default_style_name - if style_name in ["iqp", "default"]: - current_style = DefaultStyle().style + if style_name in ["iqp", default_style_name]: + current_style = default_style().style else: # Search for file in 'styles' dir, then config_path, and finally the current directory style_name = style_name + ".json" @@ -221,7 +228,7 @@ def load_style(style: dict | str | None) -> tuple[StyleDict, float]: # check configured paths, if there are any if config: - config_path = config.get("circuit_mpl_style_path", "") + config_path = config.get(user_config_path_opt, "") if config_path: for path in config_path: style_paths.append(Path(path) / style_name) @@ -238,7 +245,7 @@ def load_style(style: dict | str | None) -> tuple[StyleDict, float]: with open(exp_user) as infile: json_style = json.load(infile) - current_style = StyleDict(json_style) + current_style = style_dict(json_style) break except json.JSONDecodeError as err: warn( @@ -263,7 +270,7 @@ def load_style(style: dict | str | None) -> tuple[StyleDict, float]: UserWarning, 2, ) - current_style = DefaultStyle().style + current_style = default_style().style # if the style is a dictionary, update the defaults with the new values # this _needs_ to happen after loading by name to cover cases like diff --git a/qiskit/visualization/dag/__init__.py b/qiskit/visualization/dag/__init__.py new file mode 100644 index 000000000000..edbac45d60af --- /dev/null +++ b/qiskit/visualization/dag/__init__.py @@ -0,0 +1,13 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2014. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Empty init for data directory.""" diff --git a/qiskit/visualization/dag/dagstyle.py b/qiskit/visualization/dag/dagstyle.py new file mode 100644 index 000000000000..370571f23019 --- /dev/null +++ b/qiskit/visualization/dag/dagstyle.py @@ -0,0 +1,127 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2019. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Matplotlib circuit visualization style.""" + +from __future__ import annotations + +import json +import os +from typing import Any +from pathlib import Path +from warnings import warn +from qiskit.visualization import exceptions + + +class DAGStyleDict(dict): + """A dictionary for graphviz styles. + + Defines additional abbreviations for key accesses, such as allowing + ``"ec"`` instead of writing ``"edgecolor"``. + """ + + VALID_FIELDS = { + "name", + "fontsize", + "bgcolor", + "dpi", + "pad", + "nodecolor", + "inputnodecolor", + "inputnodefontcolor", + "outputnodecolor", + "outputnodefontcolor", + "opnodecolor", + "opnodefontcolor", + "edgecolor", + "qubitedgecolor", + "clbitedgecolor", + } + + ABBREVIATIONS = { + "nc": "nodecolor", + "ic": "inputnodecolor", + "if": "inputnodefontcolor", + "oc": "outputnodecolor", + "of": "outputnodefontcolor", + "opc": "opnodecolor", + "opf": "opnodefontcolor", + "ec": "edgecolor", + "qec": "qubitedgecolor", + "clc": "clbitedgecolor", + } + + def __setitem__(self, key: Any, value: Any) -> None: + # allow using field abbreviations + if key in self.ABBREVIATIONS: + key = self.ABBREVIATIONS[key] + + if key not in self.VALID_FIELDS: + warn( + f"style option ({key}) is not supported", + UserWarning, + 2, + ) + return super().__setitem__(key, value) + + def __getitem__(self, key: Any) -> Any: + # allow using field abbreviations + if key in self.ABBREVIATIONS: + key = self.ABBREVIATIONS[key] + + return super().__getitem__(key) + + def update(self, other): + super().update((key, value) for key, value in other.items()) + + +class DAGDefaultStyle: + """Creates a Default Style dictionary + + The style dict contains numerous options that define the style of the + output circuit visualization. The style dict is used by the `mpl` or + `latex` output. The options available in the style dict are defined below: + + Attributes: + name (str): The name of the style. + fontsize (str): The font size to use for text. + bgcolor (str): The color name to use for the background ('red', 'green', etc.). + nodecolor (str): The color to use for all nodes. + dpi (int): The DPI to use for the output image. + pad (int): A number to adjust padding around output + graph. + inputnodecolor (str): The color to use for incoming wire nodes. Overrides + nodecolor for those nodes. + inputnodefontcolor (str): The font color to use for incoming wire nodes. + Overrides nodecolor for those nodes. + outputnodecolor (str): The color to use for output wire nodes. Overrides + nodecolor for those nodes. + outputnodefontcolor (str): The font color to use for output wire nodes. + Overrides nodecolor for those nodes. + opnodecolor (str): The color to use for Instruction nodes. Overrides + nodecolor for those nodes. + opnodefontcolor (str): The font color to use for Instruction nodes. + Overrides nodecolor for those nodes. + + qubitedgecolor (str): The edge color for qubits. Overrides edgecolor for these edges. + clbitedgecolor (str): The edge color for clbits. Overrides edgecolor for these edges. + """ + + def __init__(self): + default_style_dict = "color.json" + path = Path(__file__).parent / "styles" / default_style_dict + + with open(path, "r") as infile: + default_style = json.load(infile) + + # set shortcuts, such as "ec" for "edgecolor" + self.style = DAGStyleDict(**default_style) \ No newline at end of file diff --git a/qiskit/visualization/dag/styles/__init__.py b/qiskit/visualization/dag/styles/__init__.py new file mode 100644 index 000000000000..edbac45d60af --- /dev/null +++ b/qiskit/visualization/dag/styles/__init__.py @@ -0,0 +1,13 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2014. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""Empty init for data directory.""" diff --git a/qiskit/visualization/dag/styles/color.json b/qiskit/visualization/dag/styles/color.json new file mode 100644 index 000000000000..814584f20482 --- /dev/null +++ b/qiskit/visualization/dag/styles/color.json @@ -0,0 +1,10 @@ +{ + "name": "color", + "inputnodecolor": "green", + "outputnodecolor": "red", + "opnodecolor": "lightblue", + "barriercolor": "grey", + "directivecolor": "red", + "conditioncolor": "green", + "measurecolor": "white" +} \ No newline at end of file diff --git a/qiskit/visualization/dag/styles/plain.json b/qiskit/visualization/dag/styles/plain.json new file mode 100644 index 000000000000..e452a5ddd43a --- /dev/null +++ b/qiskit/visualization/dag/styles/plain.json @@ -0,0 +1,6 @@ +{ + "name": "plain", + "inputnodecolor": "white", + "outputnodecolor": "white", + "opnodecolor": "white" +} diff --git a/qiskit/visualization/dag_visualization.py b/qiskit/visualization/dag_visualization.py index c80a753174c1..ac1f9cb34d60 100644 --- a/qiskit/visualization/dag_visualization.py +++ b/qiskit/visualization/dag_visualization.py @@ -30,6 +30,9 @@ from qiskit.exceptions import InvalidFileError from .exceptions import VisualizationError +from .circuit.qcstyle import load_style +from .dag.dagstyle import DAGStyleDict, DAGDefaultStyle + IMAGE_TYPES = { "canon", @@ -73,7 +76,12 @@ @_optionals.HAS_GRAPHVIZ.require_in_call @_optionals.HAS_PIL.require_in_call -def dag_drawer(dag, scale=0.7, filename=None, style="color"): +def dag_drawer( + dag, + scale=0.7, + filename=None, + style="color", +): """Plot the directed acyclic graph (dag) to represent operation dependencies in a quantum circuit. @@ -84,8 +92,19 @@ def dag_drawer(dag, scale=0.7, filename=None, style="color"): dag (DAGCircuit or DAGDependency): The dag to draw. scale (float): scaling factor filename (str): file path to save image to (format inferred from name) - style (str): 'plain': B&W graph - 'color' (default): color input/output/op nodes + style (dict or str): Style name, file name of style JSON file, or a + dictionary specifying the style. + + * The supported style names are 'plain': B&W graph, 'color' (default): + (color input/output/op nodes) + * If given a JSON file, e.g. ``my_style.json`` or ``my_style`` (the ``.json`` + extension may be omitted), this function attempts to load the style dictionary + from that location. Note, that the JSON file must completely specify the + visualization specifications. The file is searched for in + ``qiskit/visualization/circuit/styles``, the current working directory, and + the location specified in ``~/.qiskit/settings.conf``. + * If ``None`` the default style ``"color"`` is used or, if given, the default style + specified in ``~/.qiskit/settings.conf``. Returns: PIL.Image: if in Jupyter notebook and not saving to file, @@ -99,11 +118,10 @@ def dag_drawer(dag, scale=0.7, filename=None, style="color"): Example: .. plot:: - :include-source: - :nofigs: + :include-source: + :nofigs: from qiskit import QuantumRegister, ClassicalRegister, QuantumCircuit - from qiskit.dagcircuit import DAGCircuit from qiskit.converters import circuit_to_dag from qiskit.visualization import dag_drawer @@ -116,8 +134,16 @@ def dag_drawer(dag, scale=0.7, filename=None, style="color"): circ.rz(0.5, q[1]).c_if(c, 2) dag = circuit_to_dag(circ) - dag_drawer(dag) + + style = { + "inputnodecolor": "pink", + "outputnodecolor": "lightblue", + "opnodecolor": "red", + } + + dag_drawer(dag, style=style) """ + from PIL import Image # NOTE: use type str checking to avoid potential cyclical import @@ -129,13 +155,30 @@ def dag_drawer(dag, scale=0.7, filename=None, style="color"): for reg in list(dag.qregs.values()) + list(dag.cregs.values()) for (idx, bit) in enumerate(reg) } + + graph_attrs = {} + if isinstance(style, dict): + for attr in ["fontsize", "bgcolor", "dpi", "pad"]: + if attr in style: + graph_attrs[attr] = str(style[attr]) + + style, _ = load_style( + style, + style_dict=DAGStyleDict, + default_style=DAGDefaultStyle, + default_style_name="color", + user_config_opt="circuit_graphviz_style", + user_config_path_opt="circuit_graphviz_style_path" + ) + if "DAGDependency" in type_str: # pylint: disable=cyclic-import from qiskit.visualization.circuit._utils import get_bit_reg_index qubit_indices = {bit: index for index, bit in enumerate(dag.qubits)} clbit_indices = {bit: index for index, bit in enumerate(dag.clbits)} - graph_attrs = {"dpi": str(100 * scale)} + graph_attrs.update({"dpi": str(100 * scale)}) + dag_dep_circ = dagdependency_to_circuit(dag) def node_attr_func(node): @@ -143,63 +186,64 @@ def node_attr_func(node): nid_str = str(node._node_id) else: nid_str = str(node.node_id) - if style == "plain": - return {} - if style == "color": - n = {} - args = [] - for count, arg in enumerate(node.qargs + node.cargs): - if count > 4: - args.append("...") - break - if isinstance(arg, Qubit): - f_str = f"q_{qubit_indices[arg]}" - elif isinstance(arg, Clbit): - f_str = f"c_{clbit_indices[arg]}" - else: - f_str = f"{arg.index}" - arg_str = register_bit_labels.get(arg, f_str) - args.append(arg_str) - n["color"] = "black" + n = {} + args = [] + for count, arg in enumerate(node.qargs + node.cargs): + if count > 4: + args.append("...") + break + if isinstance(arg, Qubit): + f_str = f"q_{qubit_indices[arg]}" + elif isinstance(arg, Clbit): + f_str = f"c_{clbit_indices[arg]}" + else: + f_str = f"{arg.index}" + arg_str = register_bit_labels.get(arg, f_str) + args.append(arg_str) + + n["label"] = ( + nid_str + ": " + str(node.name) + " (" + str(args)[1:-1].replace("'", "") + ")" + ) + if getattr(node.op, "condition", None): + condition = node.op.condition + if isinstance(condition, expr.Expr): + cond_txt = " (cond: [Expr]) (" + elif isinstance(condition[0], ClassicalRegister): + cond_txt = f" (cond: {condition[0].name}, {int(condition[1])}) (" + else: + register, bit_index, reg_index = get_bit_reg_index(dag_dep_circ, condition[0]) + if register is not None: + cond_txt = f" (cond: {register.name}[{reg_index}], {int(condition[1])}) (" + else: + cond_txt = f" (cond: {bit_index}, {int(condition[1])}) (" n["label"] = ( - nid_str + ": " + str(node.name) + " (" + str(args)[1:-1].replace("'", "") + ")" + nid_str + + ": " + + str(node.name) + + cond_txt + + str(args)[1:-1].replace("'", "") + + ")" ) + + if isinstance(style, dict): + n["style"] = "filled" + + if "nodecolor" in style: + n["fillcolor"] = style["nodecolor"] + + if "fontsize" in style: + n["fontsize"] = str(style["fontsize"]) + if node.name == "barrier": - n["style"] = "filled" - n["fillcolor"] = "grey" + n["fillcolor"] = style["barriercolor"] elif getattr(node.op, "_directive", False): - n["style"] = "filled" - n["fillcolor"] = "red" + n["fillcolor"] = style["directivecolor"] elif getattr(node.op, "condition", None): - condition = node.op.condition - if isinstance(condition, expr.Expr): - cond_txt = " (cond: [Expr]) (" - elif isinstance(condition[0], ClassicalRegister): - cond_txt = f" (cond: {condition[0].name}, {int(condition[1])}) (" - else: - register, bit_index, reg_index = get_bit_reg_index( - dag_dep_circ, condition[0] - ) - if register is not None: - cond_txt = ( - f" (cond: {register.name}[{reg_index}], {int(condition[1])}) (" - ) - else: - cond_txt = f" (cond: {bit_index}, {int(condition[1])}) (" - n["style"] = "filled" - n["fillcolor"] = "green" - n["label"] = ( - nid_str - + ": " - + str(node.name) - + cond_txt - + str(args)[1:-1].replace("'", "") - + ")" - ) - elif node.name != "measure": # measure is unfilled - n["style"] = "filled" - n["fillcolor"] = "lightblue" + n["fillcolor"] = style["conditioncolor"] + elif node.name == "measure": + n["fillcolor"] = style["measurecolor"] + return n else: raise VisualizationError(f"Unrecognized style {style} for the dag_drawer.") @@ -207,55 +251,66 @@ def node_attr_func(node): edge_attr_func = None else: - graph_attrs = {"dpi": str(100 * scale)} + graph_attrs.update({"dpi": str(100 * scale)}) def node_attr_func(node): - if style == "plain": - return {} - if style == "color": - n = {} - if isinstance(node, DAGOpNode): - n["label"] = node.name - n["color"] = "blue" - n["style"] = "filled" - n["fillcolor"] = "lightblue" - if isinstance(node, DAGInNode): - if isinstance(node.wire, Qubit): - label = register_bit_labels.get( - node.wire, f"q_{dag.find_bit(node.wire).index}" - ) - elif isinstance(node.wire, Clbit): - label = register_bit_labels.get( - node.wire, f"c_{dag.find_bit(node.wire).index}" - ) - else: - label = str(node.wire.name) + n = {} + if isinstance(node, DAGOpNode): + n["label"] = node.name + if isinstance(node, DAGInNode): + if isinstance(node.wire, Qubit): + label = register_bit_labels.get(node.wire, f"q_{dag.find_bit(node.wire).index}") + elif isinstance(node.wire, Clbit): + label = register_bit_labels.get(node.wire, f"c_{dag.find_bit(node.wire).index}") + else: + label = str(node.wire.name) + + n["label"] = label + if isinstance(node, DAGOutNode): + if isinstance(node.wire, Qubit): + label = register_bit_labels.get( + node.wire, f"q[{dag.find_bit(node.wire).index}]" + ) + elif isinstance(node.wire, Clbit): + label = register_bit_labels.get( + node.wire, f"c[{dag.find_bit(node.wire).index}]" + ) + else: + label = str(node.wire.name) + n["label"] = label + + if isinstance(style, dict): + n["style"] = "filled" + + if "nodecolor" in style: + n["fillcolor"] = style["nodecolor"] + + if "fontsize" in style: + n["fontsize"] = str(style["fontsize"]) - n["label"] = label - n["color"] = "black" - n["style"] = "filled" - n["fillcolor"] = "green" + if isinstance(node, DAGInNode): + if "inputnodecolor" in style: + n["fillcolor"] = style["inputnodecolor"] + if "inputnodefontcolor" in style: + n["fontcolor"] = style["inputnodefontcolor"] if isinstance(node, DAGOutNode): - if isinstance(node.wire, Qubit): - label = register_bit_labels.get( - node.wire, f"q[{dag.find_bit(node.wire).index}]" - ) - elif isinstance(node.wire, Clbit): - label = register_bit_labels.get( - node.wire, f"c[{dag.find_bit(node.wire).index}]" - ) - else: - label = str(node.wire.name) - n["label"] = label - n["color"] = "black" - n["style"] = "filled" - n["fillcolor"] = "red" + if "outputnodecolor" in style: + n["fillcolor"] = style["outputnodecolor"] + if "outputnodefontcolor" in style: + n["fontcolor"] = style["outputnodefontcolor"] + if isinstance(node, DAGOpNode): + if "opnodecolor" in style: + n["fillcolor"] = style["opnodecolor"] + if "opnodefontcolor" in style: + n["fontcolor"] = style["opnodefontcolor"] + return n else: - raise VisualizationError(f"Invalid style {style}") + raise VisualizationError(f"Invalid style {style}, {type(style)}") def edge_attr_func(edge): e = {} + if isinstance(edge, Qubit): label = register_bit_labels.get(edge, f"q_{dag.find_bit(edge).index}") elif isinstance(edge, Clbit): @@ -263,6 +318,21 @@ def edge_attr_func(edge): else: label = str(edge.name) e["label"] = label + + if isinstance(style, dict): + if "edgecolor" in style: + e["color"] = style["edgecolor"] + if "fontsize" in style: + e["fontsize"] = str(style["fontsize"]) + + if isinstance(edge, Qubit): + if "qubitedgecolor" in style: + e["color"] = style["qubitedgecolor"] + if isinstance(edge, Clbit): + if "clbitedgecolor" in style: + e["color"] = style["clbitedgecolor"] + return e + return e image_type = "png" @@ -306,6 +376,7 @@ def edge_attr_func(edge): text=True, ) return None + else: return graphviz_draw( dag._multi_graph, diff --git a/releasenotes/notes/dag_drawer_custom_style-ec99bd874858f6a6.yaml b/releasenotes/notes/dag_drawer_custom_style-ec99bd874858f6a6.yaml new file mode 100644 index 000000000000..0db44bc7e274 --- /dev/null +++ b/releasenotes/notes/dag_drawer_custom_style-ec99bd874858f6a6.yaml @@ -0,0 +1,33 @@ +--- +features_visualization: + - | + Introduced custom styles for the dag_drawer() function. This allows you + to pass a dictionary to the `style` parameter with custom attributes that + changes the style of the DAG the function returns. For example:: + + from qiskit import QuantumRegister, ClassicalRegister, QuantumCircuit + from qiskit.converters import circuit_to_dag + from qiskit.visualization import dag_drawer + + q = QuantumRegister(3, 'q') + c = ClassicalRegister(3, 'c') + circ = QuantumCircuit(q, c) + circ.h(q[0]) + circ.cx(q[0], q[1]) + circ.measure(q[0], c[0]) + circ.rz(0.5, q[1]).c_if(c, 2) + + dag = circuit_to_dag(circ) + + style = { + "inputnodecolor": "pink", + "outputnodecolor": "lightblue", + "opnodecolor": "red", + } + + dag_drawer(dag, style=style) +fixes: + - | + Fixes a bug where `style=plain` did not show circuit labels for the nodes + of the DAG. + diff --git a/test/python/visualization/test_dag_drawer.py b/test/python/visualization/test_dag_drawer.py index 9c9b11e42a68..71630418c131 100644 --- a/test/python/visualization/test_dag_drawer.py +++ b/test/python/visualization/test_dag_drawer.py @@ -15,11 +15,12 @@ import os import tempfile import unittest +import itertools from qiskit.circuit import QuantumRegister, ClassicalRegister, QuantumCircuit, Qubit, Clbit, Store from qiskit.visualization import dag_drawer from qiskit.exceptions import InvalidFileError -from qiskit.visualization import VisualizationError +from qiskit.visualization.exceptions import VisualizationError from qiskit.converters import circuit_to_dag, circuit_to_dagdependency from qiskit.utils import optionals as _optionals from qiskit.dagcircuit import DAGCircuit @@ -45,6 +46,46 @@ def test_dag_drawer_invalid_style(self): with self.assertRaisesRegex(VisualizationError, "Invalid style multicolor"): dag_drawer(self.dag, style="multicolor") + @unittest.skipUnless(_optionals.HAS_GRAPHVIZ, "Graphviz not installed") + @unittest.skipUnless(_optionals.HAS_PIL, "PIL not installed") + def test_dag_drawer_empty_style(self): + """ + Test that dag_drawer() with an empty dict returns a plain DAG + """ + dag_drawer(self.dag, style={}) + + @unittest.skipUnless(_optionals.HAS_GRAPHVIZ, "Graphviz not installed") + @unittest.skipUnless(_optionals.HAS_PIL, "PIL not installed") + def test_dag_drawer_custom_style(self): + """ + Test dag with various custom styles + """ + + style = { + "fontsize": 12, + "bgcolor": "white", + "dpi": 10, + "pad": 0, + "nodecolor": "green", + "inputnodecolor": "blue", + "inputnodefontcolor": "white", + "outputnodecolor": "red", + "outputnodefontcolor": "white", + "opnodecolor": "black", + "opnodefontcolor": "white", + "edgecolor": "black", + "qubitedgecolor": "black", + "clbitedgecolor": "black", + } + + for r in range(2): + combinations = itertools.combinations(style, r) + for c in combinations: + curr_style = {x: style[x] for x in c} + dag_drawer(self.dag, style=curr_style) + + dag_drawer(self.dag, style=style) + @unittest.skipUnless(_optionals.HAS_GRAPHVIZ, "Graphviz not installed") @unittest.skipUnless(_optionals.HAS_PIL, "PIL not installed") def test_dag_drawer_checks_filename_correct_format(self):