diff --git a/psyneulink/core/components/mechanisms/mechanism.py b/psyneulink/core/components/mechanisms/mechanism.py index 4e37262018..64f1baa372 100644 --- a/psyneulink/core/components/mechanisms/mechanism.py +++ b/psyneulink/core/components/mechanisms/mechanism.py @@ -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 `_, which must be installed and imported - (standard with PsyNeuLink pip install) + This method relies on `graphviz `_ + 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 diff --git a/psyneulink/core/compositions/showgraph.py b/psyneulink/core/compositions/showgraph.py index 6d48d9e7be..e65df2815c 100644 --- a/psyneulink/core/compositions/showgraph.py +++ b/psyneulink/core/compositions/showgraph.py @@ -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: @@ -564,8 +571,11 @@ def show_graph(self, for additional details. .. note:: - This method relies on `graphviz `_, which must be installed and imported - (standard with PsyNeuLink pip install) + This method relies on `graphviz `_ + 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 --------- @@ -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) @@ -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 @@ -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 @@ -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? diff --git a/tests/composition/test_show_graph.py b/tests/composition/test_show_graph.py index 7f9ecf2b20..917884f055 100644 --- a/tests/composition/test_show_graph.py +++ b/tests/composition/test_show_graph.py @@ -1,3 +1,8 @@ +import os +import pathlib +import sys +import uuid + import numpy as np import pytest @@ -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]) @@ -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)