Skip to content

Commit

Permalink
Merge pull request PrincetonUniversity#3159 from kmantel/showgraph-gv
Browse files Browse the repository at this point in the history
showgraph, docs: give more information on system graphviz
  • Loading branch information
kmantel authored Jan 14, 2025
2 parents 4a5472c + 194ee01 commit 0ef9565
Show file tree
Hide file tree
Showing 3 changed files with 123 additions and 35 deletions.
7 changes: 5 additions & 2 deletions psyneulink/core/components/mechanisms/mechanism.py
Original file line number Diff line number Diff line change
Expand Up @@ -3376,8 +3376,11 @@ def _show_structure(self,
"""Generate a detailed display of a the structure of a Mechanism.
.. note::
This method relies on `graphviz <http://www.graphviz.org>`_, which must be installed and imported
(standard with PsyNeuLink pip install)
This method relies on `graphviz <http://www.graphviz.org>`_
python and system packages, which must be installed. The
python package comes standard with PsyNeuLink pip install,
but the system package must be installed separately. It can
be downloaded at https://www.graphviz.org/download/.
Displays the structure of a Mechanism using html table format and shape='plaintext'.
This method is called by `Composition.show_graph` if its **show_mechanism_structure** argument is specified as
Expand Down
80 changes: 47 additions & 33 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 @@ -564,8 +571,11 @@ def show_graph(self,
for additional details.
.. note::
This method relies on `graphviz <http://www.graphviz.org>`_, which must be installed and imported
(standard with PsyNeuLink pip install)
This method relies on `graphviz <http://www.graphviz.org>`_
python and system packages, which must be installed. The
python package comes standard with PsyNeuLink pip install,
but the system package must be installed separately. It can
be downloaded at https://www.graphviz.org/download/.
Arguments
---------
Expand Down Expand Up @@ -2610,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 @@ -2681,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 @@ -2915,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 @@ -2966,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 0ef9565

Please sign in to comment.