Skip to content

Commit

Permalink
showgraph: give more information when system graphviz not found
Browse files Browse the repository at this point in the history
catch graphviz ExecutableNotFound error and re-raise to explain the
source of the problem and where to download system graphviz
  • Loading branch information
kmantel committed Jan 10, 2025
1 parent a044979 commit 194ee01
Show file tree
Hide file tree
Showing 2 changed files with 113 additions and 31 deletions.
73 changes: 42 additions & 31 deletions psyneulink/core/compositions/showgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,13 @@
default_showgraph_subdir = 'pnl-show_graph-output'


_gv_executable_not_found_error_msg = (
"Graphviz executables were not found on your systems' PATH. These are"
" required in addition to the graphviz python package and can be downloaded"
" at https://www.graphviz.org/download/"
)


def get_default_showgraph_dir():
pnl_module_dir = pathlib.Path(psyneulink.__file__).parent.absolute()
try:
Expand Down Expand Up @@ -2613,6 +2620,8 @@ def _generate_output(self,
):

from psyneulink.core.compositions.composition import Composition, NodeRole
# graphviz is currently only imported within methods
from graphviz.backend.execute import ExecutableNotFound

composition = self.composition
nodes = self._get_nodes(composition, context)
Expand Down Expand Up @@ -2684,39 +2693,35 @@ def get_index_of_node_in_G_body(node, node_type: Literal['MECHANISM', 'Projectio
# GENERATE OUTPUT ---------------------------------------------------------------------

# Show as pdf
try:
if output_fmt == 'pdf':
# G.format = 'svg'
if output_fmt == 'pdf':
# G.format = 'svg'
try:
G.view(composition.name.replace(" ", "-"), cleanup=True, directory=get_default_showgraph_dir().joinpath('PDFS'))
except ExecutableNotFound as e:
raise ShowGraphError(_gv_executable_not_found_error_msg) from e
except Exception as e:
raise ShowGraphError(f"Problem displaying graph for {composition.name}: {e}") from e

# Generate images for animation
elif output_fmt == 'gif':
if composition.active_item_rendered or INITIAL_FRAME in active_items:
self._generate_gifs(G, active_items, context)
# Generate images for animation
elif output_fmt == 'gif':
if composition.active_item_rendered or INITIAL_FRAME in active_items:
self._generate_gifs(G, active_items, context)

# Return graph to show in jupyter
elif output_fmt == 'jupyter':
return G
# Return graph to show in jupyter
elif output_fmt == 'jupyter':
return G

elif output_fmt == 'gv':
return G
elif output_fmt == 'gv':
return G

elif output_fmt == 'source':
return G.source
elif output_fmt == 'source':
return G.source

elif not output_fmt:
return None
elif not output_fmt:
return None

else:
raise ShowGraphError(f"Bad arg in call to {composition.name}.show_graph: '{output_fmt}'.")

except ShowGraphError as e:
# raise ShowGraphError(str(e.error_value))
raise ShowGraphError(str(e.error_value)) from e
# except:
# raise ShowGraphError(f"Problem displaying graph for {composition.name}")
except Exception as e:
raise ShowGraphError(f"Problem displaying graph for {composition.name}: {e}") from e
else:
raise ShowGraphError(f"Bad arg in call to {composition.name}.show_graph: '{output_fmt}'.")

def _is_composition_controller(self, mech, context, enclosing_comp=None):
# FIX 6/12/20: REPLACE WITH TEST FOR NodeRole.CONTROLLER ONCE THAT IS IMPLEMENTED
Expand Down Expand Up @@ -2918,6 +2923,8 @@ def _animate_execution(self, active_items, context):
)

def _generate_gifs(self, G, active_items, context):
# graphviz is currently only imported within methods
from graphviz.backend.execute import ExecutableNotFound

composition = self.composition

Expand Down Expand Up @@ -2969,11 +2976,15 @@ def create_time_string(time, spec):
index = repr(composition._component_animation_execution_count)
image_filename = '-'.join([repr(run_num), repr(trial_num), index])
image_file = pathlib.Path(composition._animation_directory, image_filename + '.gif')
G.render(filename=image_filename,
directory=composition._animation_directory,
cleanup=True,
# view=True
)
try:
G.render(
filename=image_filename,
directory=composition._animation_directory,
cleanup=True,
# view=True
)
except ExecutableNotFound as e:
raise ShowGraphError(_gv_executable_not_found_error_msg) from e
# Append gif to composition._animation
image = Image.open(image_file)
# TBI?
Expand Down
71 changes: 71 additions & 0 deletions tests/composition/test_show_graph.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import os
import pathlib
import sys
import uuid

import numpy as np
import pytest

Expand All @@ -15,14 +20,24 @@
from psyneulink.core.components.ports.modulatorysignals.controlsignal import ControlSignal
from psyneulink.core.components.projections.pathway.mappingprojection import MappingProjection
from psyneulink.core.compositions.composition import Composition, NodeRole
from psyneulink.core.compositions.showgraph import (
ShowGraphError,
_gv_executable_not_found_error_msg,
)
from psyneulink.core.globals.keywords import ALL, INSET, INTERCEPT, NESTED, NOISE, SLOPE
from psyneulink.library.components.mechanisms.modulatory.control.agt.lccontrolmechanism import LCControlMechanism
from psyneulink.library.components.mechanisms.processing.integrator.ddm import DDM
from psyneulink.library.components.mechanisms.processing.integrator.episodicmemorymechanism import \
EpisodicMemoryMechanism, VALUE_INPUT, VALUE_OUTPUT, KEY_INPUT, KEY_OUTPUT

graphviz_executables = {
'dot', 'neato', 'twopi', 'circo', 'fdp', 'sfdp', 'patchwork', 'osage'
}


"""These test various elaborate forms of Composition configuration and nesting, in addition to show_graph itself"""


class TestSimpleCompositions:
def test_process(self):
a = TransferMechanism(name="a", default_variable=[0, 0, 0])
Expand Down Expand Up @@ -819,3 +834,59 @@ def test_projections_from_nested_comp_to_ocm_or_obj_mech(self, show_graph_kwargs
# assert gv == repr(expected_gv_correct)
# except AssertionError:
# assert gv == repr(expected_gv_incorrect)


def _mock_gv_missing_executable(monkeypatch_context):
"""
replace directory of graphviz executable with a uuid that shouldn't
exist, so that the executable won't be found by subprocess system
calls
"""
if sys.platform == 'win32':
import _winapi
orig_CreateProcess = _winapi.CreateProcess

def mock_CreateProcess_gv_fail(executable, args, *a, **kwargs):
if any(args.startswith(f'{ex} ') for ex in graphviz_executables):
args = f'{uuid.uuid4()}\\{args}'
return orig_CreateProcess(executable, args, *a, **kwargs)

monkeypatch_context.setattr(_winapi, 'CreateProcess', mock_CreateProcess_gv_fail)
else:
orig_dirname = os.path.dirname

def mock_dirname_gv_fail(p):
q = p
try:
q = q.decode('utf-8')
except AttributeError:
# may be str or bytes
pass

if pathlib.Path(q).name in graphviz_executables:
return uuid.uuid4()

return orig_dirname(p)

monkeypatch_context.setattr(os.path, 'dirname', mock_dirname_gv_fail)


def _test_graphviz_not_found(monkeypatch, comp_func, **kwargs):
a = TransferMechanism()
b = TransferMechanism()
comp = Composition([a, b])

with monkeypatch.context() as m:
_mock_gv_missing_executable(m)
with pytest.raises(ShowGraphError, match=_gv_executable_not_found_error_msg):
getattr(comp, comp_func)(**kwargs)


# other output_fmt do not fail on static show_graph
@pytest.mark.parametrize('output_fmt', ['pdf'])
def test_graphviz_not_found_static(monkeypatch, output_fmt):
_test_graphviz_not_found(monkeypatch, 'show_graph', output_fmt=output_fmt)


def test_graphviz_not_found_animated(monkeypatch):
_test_graphviz_not_found(monkeypatch, 'run', animate=True)

0 comments on commit 194ee01

Please sign in to comment.