From 1c64b80f321c80ca73cab025c78db652119e2f51 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Thu, 8 Feb 2024 15:32:51 +0100 Subject: [PATCH 1/8] Enhance Pipeline.draw() to show image directly in Jupyter notebook --- .../pipeline/{draw/mermaid.py => draw.py} | 72 +++++++++- haystack/core/pipeline/draw/__init__.py | 3 - haystack/core/pipeline/draw/draw.py | 100 -------------- haystack/core/pipeline/draw/graphviz.py | 41 ------ haystack/core/pipeline/pipeline.py | 28 ++-- ...nhance-pipeline-draw-5fe3131db71f6f54.yaml | 6 + test/core/conftest.py | 5 +- test/core/pipeline/test_draw.py | 128 ++++++++++++------ test/core/pipeline/test_draw_graphviz.py | 26 ---- 9 files changed, 178 insertions(+), 231 deletions(-) rename haystack/core/pipeline/{draw/mermaid.py => draw.py} (61%) delete mode 100644 haystack/core/pipeline/draw/__init__.py delete mode 100644 haystack/core/pipeline/draw/draw.py delete mode 100644 haystack/core/pipeline/draw/graphviz.py create mode 100644 releasenotes/notes/enhance-pipeline-draw-5fe3131db71f6f54.yaml delete mode 100644 test/core/pipeline/test_draw_graphviz.py diff --git a/haystack/core/pipeline/draw/mermaid.py b/haystack/core/pipeline/draw.py similarity index 61% rename from haystack/core/pipeline/draw/mermaid.py rename to haystack/core/pipeline/draw.py index eb059c7c5c..31c9c08eab 100644 --- a/haystack/core/pipeline/draw/mermaid.py +++ b/haystack/core/pipeline/draw.py @@ -1,17 +1,85 @@ # SPDX-FileCopyrightText: 2022-present deepset GmbH # # SPDX-License-Identifier: Apache-2.0 -import logging import base64 +import logging +from pathlib import Path +from typing import Optional -import requests import networkx # type:ignore +import requests from haystack.core.errors import PipelineDrawingError +from haystack.core.pipeline.descriptions import find_pipeline_inputs, find_pipeline_outputs from haystack.core.type_utils import _type_name logger = logging.getLogger(__name__) + +def _draw(graph: networkx.MultiDiGraph, path: Optional[Path] = None) -> None: + """ + Draw a pipeline graph using Mermaid and save it to a file. + If on a Jupyter notebook, it will also display the image inline. + """ + image_data = _to_mermaid_image(_prepare_for_drawing(graph)) + + in_notebook = False + try: + from IPython.core.getipython import get_ipython + from IPython.display import Image, display + + if "IPKernelApp" in get_ipython().config: + # We're in a notebook, let's display the image + display(Image(image_data)) + in_notebook = True + except ImportError: + pass + except AttributeError: + pass + + if not in_notebook and not path: + # We're not in a notebook and no path is given, the user must have forgot + # to specify the path. Raise an error. + msg = "No path specified to save the image to." + raise ValueError(msg) + + if path: + # If we reached this point we're in a notebook and the user has specified a path. + # Let's save the image anyway even if it's been displayed in the notebook. + Path(path).write_bytes(image_data) + + +def _prepare_for_drawing(graph: networkx.MultiDiGraph) -> networkx.MultiDiGraph: + """ + Add some extra nodes to show the inputs and outputs of the pipeline. + Also adds labels to edges. + """ + # Label the edges + for inp, outp, key, data in graph.edges(keys=True, data=True): + data[ + "label" + ] = f"{data['from_socket'].name} -> {data['to_socket'].name}{' (opt.)' if not data['mandatory'] else ''}" + graph.add_edge(inp, outp, key=key, **data) + + # Add inputs fake node + graph.add_node("input") + for node, in_sockets in find_pipeline_inputs(graph).items(): + for in_socket in in_sockets: + if not in_socket.senders and in_socket.is_mandatory: + # If this socket has no sender it could be a socket that receives input + # directly when running the Pipeline. We can't know that for sure, in doubt + # we draw it as receiving input directly. + graph.add_edge("input", node, label=in_socket.name, conn_type=_type_name(in_socket.type)) + + # Add outputs fake node + graph.add_node("output") + for node, out_sockets in find_pipeline_outputs(graph).items(): + for out_socket in out_sockets: + graph.add_edge(node, "output", label=out_socket.name, conn_type=_type_name(out_socket.type)) + + return graph + + ARROWTAIL_MANDATORY = "--" ARROWTAIL_OPTIONAL = "-." ARROWHEAD_MANDATORY = "-->" diff --git a/haystack/core/pipeline/draw/__init__.py b/haystack/core/pipeline/draw/__init__.py deleted file mode 100644 index c1764a6e03..0000000000 --- a/haystack/core/pipeline/draw/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# SPDX-FileCopyrightText: 2022-present deepset GmbH -# -# SPDX-License-Identifier: Apache-2.0 diff --git a/haystack/core/pipeline/draw/draw.py b/haystack/core/pipeline/draw/draw.py deleted file mode 100644 index c68ac3277f..0000000000 --- a/haystack/core/pipeline/draw/draw.py +++ /dev/null @@ -1,100 +0,0 @@ -# SPDX-FileCopyrightText: 2022-present deepset GmbH -# -# SPDX-License-Identifier: Apache-2.0 -from typing import Literal, Optional, Dict, get_args, Any - -import logging -from pathlib import Path - -import networkx # type:ignore - -from haystack.core.pipeline.descriptions import find_pipeline_inputs, find_pipeline_outputs -from haystack.core.pipeline.draw.graphviz import _to_agraph -from haystack.core.pipeline.draw.mermaid import _to_mermaid_image, _to_mermaid_text -from haystack.core.type_utils import _type_name - -logger = logging.getLogger(__name__) -RenderingEngines = Literal["graphviz", "mermaid-image", "mermaid-text"] - - -def _draw( - graph: networkx.MultiDiGraph, - path: Path, - engine: RenderingEngines = "mermaid-image", - style_map: Optional[Dict[str, str]] = None, -) -> None: - """ - Renders the pipeline graph and saves it to file. - """ - converted_graph = _convert(graph=graph, engine=engine, style_map=style_map) - - if engine == "graphviz": - converted_graph.draw(path) - - elif engine == "mermaid-image": - with open(path, "wb") as imagefile: - imagefile.write(converted_graph) - - elif engine == "mermaid-text": - with open((path), "w", encoding="utf-8") as textfile: - textfile.write(converted_graph) - - else: - raise ValueError(f"Unknown rendering engine '{engine}'. Choose one from: {get_args(RenderingEngines)}.") - - logger.debug("Pipeline diagram saved at %s", path) - - -def _convert( - graph: networkx.MultiDiGraph, engine: RenderingEngines = "mermaid-image", style_map: Optional[Dict[str, str]] = None -) -> Any: - """ - Renders the pipeline graph with the correct render and returns it. - """ - graph = _prepare_for_drawing(graph=graph, style_map=style_map or {}) - - if engine == "graphviz": - return _to_agraph(graph=graph) - - if engine == "mermaid-image": - return _to_mermaid_image(graph=graph) - - if engine == "mermaid-text": - return _to_mermaid_text(graph=graph) - - raise ValueError(f"Unknown rendering engine '{engine}'. Choose one from: {get_args(RenderingEngines)}.") - - -def _prepare_for_drawing(graph: networkx.MultiDiGraph, style_map: Dict[str, str]) -> networkx.MultiDiGraph: - """ - Prepares the graph to be drawn: adds explitic input and output nodes, labels the edges, applies the styles, etc. - """ - # Apply the styles - if style_map: - for node, style in style_map.items(): - graph.nodes[node]["style"] = style - - # Label the edges - for inp, outp, key, data in graph.edges(keys=True, data=True): - data[ - "label" - ] = f"{data['from_socket'].name} -> {data['to_socket'].name}{' (opt.)' if not data['mandatory'] else ''}" - graph.add_edge(inp, outp, key=key, **data) - - # Draw the inputs - graph.add_node("input") - for node, in_sockets in find_pipeline_inputs(graph).items(): - for in_socket in in_sockets: - if not in_socket.senders and in_socket.is_mandatory: - # If this socket has no sender it could be a socket that receives input - # directly when running the Pipeline. We can't know that for sure, in doubt - # we draw it as receiving input directly. - graph.add_edge("input", node, label=in_socket.name, conn_type=_type_name(in_socket.type)) - - # Draw the outputs - graph.add_node("output") - for node, out_sockets in find_pipeline_outputs(graph).items(): - for out_socket in out_sockets: - graph.add_edge(node, "output", label=out_socket.name, conn_type=_type_name(out_socket.type)) - - return graph diff --git a/haystack/core/pipeline/draw/graphviz.py b/haystack/core/pipeline/draw/graphviz.py deleted file mode 100644 index fb7f311ba3..0000000000 --- a/haystack/core/pipeline/draw/graphviz.py +++ /dev/null @@ -1,41 +0,0 @@ -# SPDX-FileCopyrightText: 2022-present deepset GmbH -# -# SPDX-License-Identifier: Apache-2.0 -import logging - -import networkx # type:ignore - -from networkx.drawing.nx_agraph import to_agraph as nx_to_agraph # type:ignore - - -logger = logging.getLogger(__name__) - -# pyright: reportMissingImports=false -# pylint: disable=unused-import,import-outside-toplevel - - -def _to_agraph(graph: networkx.MultiDiGraph): - """ - Renders a pipeline graph using PyGraphViz. You need to install it and all its system dependencies for it to work. - """ - try: - import pygraphviz # type: ignore - except (ModuleNotFoundError, ImportError) as exc: - raise ImportError( - "Can't use 'pygraphviz' to draw this pipeline: pygraphviz could not be imported. " - "Make sure pygraphviz is installed and all its system dependencies are setup correctly." - ) from exc - - for inp, outp, key, data in graph.out_edges("input", keys=True, data=True): - data["style"] = "dashed" - graph.add_edge(inp, outp, key=key, **data) - - for inp, outp, key, data in graph.in_edges("output", keys=True, data=True): - data["style"] = "dashed" - graph.add_edge(inp, outp, key=key, **data) - - graph.nodes["input"]["shape"] = "plain" - graph.nodes["output"]["shape"] = "plain" - agraph = nx_to_agraph(graph) - agraph.layout("dot") - return agraph diff --git a/haystack/core/pipeline/pipeline.py b/haystack/core/pipeline/pipeline.py index eb38e4dc18..c76b548aa9 100644 --- a/haystack/core/pipeline/pipeline.py +++ b/haystack/core/pipeline/pipeline.py @@ -12,11 +12,12 @@ from haystack.core.component import Component, InputSocket, OutputSocket, component from haystack.core.errors import PipelineConnectError, PipelineError, PipelineRuntimeError, PipelineValidationError -from haystack.core.pipeline.descriptions import find_pipeline_inputs, find_pipeline_outputs -from haystack.core.pipeline.draw.draw import RenderingEngines, _draw from haystack.core.serialization import component_from_dict, component_to_dict from haystack.core.type_utils import _type_name, _types_are_compatible +from .descriptions import find_pipeline_inputs, find_pipeline_outputs +from .draw import _draw + logger = logging.getLogger(__name__) # We use a generic type to annotate the return value of classmethods, @@ -441,24 +442,17 @@ def outputs(self) -> Dict[str, Dict[str, Any]]: } return outputs - def draw(self, path: Path, engine: RenderingEngines = "mermaid-image") -> None: + def draw(self, path: Optional[Path] = None) -> None: """ - Draws the pipeline. Requires either `graphviz` as a system dependency, or an internet connection for Mermaid. - Run `pip install graphviz` or `pip install mermaid` to install missing dependencies. - - Args: - path: where to save the diagram. - engine: which format to save the graph as. Accepts 'graphviz', 'mermaid-text', 'mermaid-image'. - Default is 'mermaid-image'. + Save a Pipeline image to `path`. + If `path` is `None` the image will be displayed inline in the Jupyter notebook. + If `path` is `None` and the code is not running in a Jupyter notebook, an error will be raised. - Returns: - None - - Raises: - ImportError: if `engine='graphviz'` and `pygraphviz` is not installed. - HTTPConnectionError: (and similar) if the internet connection is down or other connection issues. + If `path` is given it will always be saved to file, whether it's a notebook or not. """ - _draw(graph=networkx.MultiDiGraph(self.graph), path=path, engine=engine) + # Before drawing we edit a bit the graph, to avoid modifying the original that is + # used for running the pipeline we copy it. + _draw(graph=self.graph.copy(), path=path) def warm_up(self): """ diff --git a/releasenotes/notes/enhance-pipeline-draw-5fe3131db71f6f54.yaml b/releasenotes/notes/enhance-pipeline-draw-5fe3131db71f6f54.yaml new file mode 100644 index 0000000000..92d3264db6 --- /dev/null +++ b/releasenotes/notes/enhance-pipeline-draw-5fe3131db71f6f54.yaml @@ -0,0 +1,6 @@ +--- +enhancements: + - | + `Pipeline.draw()` will now show the generated image inline if run in a Jupyter notebook. + It can also be called without a path to save the image if in a notebook. In all other cases + it will raise a `ValueError`. diff --git a/test/core/conftest.py b/test/core/conftest.py index 1c2fde1b86..f4b2268767 100644 --- a/test/core/conftest.py +++ b/test/core/conftest.py @@ -1,9 +1,8 @@ from pathlib import Path +from unittest.mock import MagicMock, patch import pytest -from unittest.mock import patch, MagicMock - @pytest.fixture def test_files(): @@ -15,7 +14,7 @@ def mock_mermaid_request(test_files): """ Prevents real requests to https://mermaid.ink/ """ - with patch("haystack.core.pipeline.draw.mermaid.requests.get") as mock_get: + with patch("haystack.core.pipeline.draw.requests.get") as mock_get: mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = open(test_files / "mermaid_mock" / "test_response.png", "rb").read() diff --git a/test/core/pipeline/test_draw.py b/test/core/pipeline/test_draw.py index a142f01c57..be8bca3444 100644 --- a/test/core/pipeline/test_draw.py +++ b/test/core/pipeline/test_draw.py @@ -1,41 +1,114 @@ # SPDX-FileCopyrightText: 2022-present deepset GmbH # # SPDX-License-Identifier: Apache-2.0 -import os -import filecmp +from unittest.mock import MagicMock, patch -from unittest.mock import patch, MagicMock import pytest import requests -from haystack.core.pipeline import Pipeline -from haystack.core.pipeline.draw.draw import _draw, _convert from haystack.core.errors import PipelineDrawingError -from haystack.testing.sample_components import Double, AddFixedValue +from haystack.core.pipeline import Pipeline +from haystack.core.pipeline.draw import _draw, _prepare_for_drawing, _to_mermaid_image, _to_mermaid_text +from haystack.testing.sample_components import AddFixedValue, Double + + +@patch("haystack.core.pipeline.draw._to_mermaid_image") +def test_draw_does_not_edit_graph(mock_to_mermaid_image, tmp_path): + mock_to_mermaid_image.return_value = b"some_image_data" + + pipe = Pipeline() + pipe.add_component("comp1", Double()) + pipe.add_component("comp2", Double()) + pipe.connect("comp1", "comp2") + pipe.connect("comp2", "comp1") + + before_draw = pipe.to_dict() + image_path = tmp_path / "test.png" + pipe.draw(path=image_path) + + assert before_draw == pipe.to_dict() + + assert image_path.read_bytes() == mock_to_mermaid_image.return_value + + +@patch("haystack.core.pipeline.draw._to_mermaid_image") +@patch("IPython.core.getipython.get_ipython") +@patch("IPython.display.Image") +@patch("IPython.display.display") +def test_draw_display_in_notebook(mock_ipython_display, mock_ipython_image, mock_get_ipython, mock_to_mermaid_image): + pipe = Pipeline() + pipe.add_component("comp1", Double()) + pipe.add_component("comp2", Double()) + pipe.connect("comp1", "comp2") + pipe.connect("comp2", "comp1") + + mock_to_mermaid_image.return_value = b"some_image_data" + mock_get_ipython.return_value = MagicMock(config={"IPKernelApp": True}) + + _draw(pipe.graph) + mock_ipython_image.assert_called_once_with(b"some_image_data") + mock_ipython_display.assert_called_once() + + +@patch("haystack.core.pipeline.draw._to_mermaid_image") +@patch("IPython.core.getipython.get_ipython") +@patch("IPython.display.Image") +@patch("IPython.display.display") +def test_draw_display_in_notebook_saves_image( + mock_ipython_display, mock_ipython_image, mock_get_ipython, mock_to_mermaid_image, tmp_path +): + pipe = Pipeline() + pipe.add_component("comp1", Double()) + pipe.add_component("comp2", Double()) + pipe.connect("comp1", "comp2") + pipe.connect("comp2", "comp1") + + mock_to_mermaid_image.return_value = b"some_image_data" + mock_get_ipython.return_value = MagicMock(config={"IPKernelApp": True}) + + image_path = tmp_path / "test.png" + _draw(pipe.graph, path=image_path) + + assert image_path.read_bytes() == mock_to_mermaid_image.return_value + + +@patch("haystack.core.pipeline.draw._to_mermaid_image") +def test_draw_raises_if_no_path_not_in_notebook(mock_to_mermaid_image, tmp_path, monkeypatch): + # Simulate not being in a notebook + monkeypatch.delattr("IPython.core.getipython") + + pipe = Pipeline() + pipe.add_component("comp1", Double()) + pipe.add_component("comp2", Double()) + pipe.connect("comp1", "comp2") + pipe.connect("comp2", "comp1") + + with pytest.raises(ValueError): + _draw(pipe.graph) @pytest.mark.integration -def test_draw_mermaid_image(tmp_path, test_files): +def test_to_mermaid_image(test_files): pipe = Pipeline() pipe.add_component("comp1", Double()) pipe.add_component("comp2", Double()) pipe.connect("comp1", "comp2") pipe.connect("comp2", "comp1") - _draw(pipe.graph, tmp_path / "test_pipe.jpg", engine="mermaid-image") - assert os.path.exists(tmp_path / "test_pipe.jpg") - assert filecmp.cmp(tmp_path / "test_pipe.jpg", test_files / "mermaid_mock" / "test_response.png") + image_data = _to_mermaid_image(_prepare_for_drawing(pipe.graph)) + test_image = test_files / "mermaid_mock" / "test_response.png" + assert test_image.read_bytes() == image_data @pytest.mark.integration -def test_draw_mermaid_img_failing_request(tmp_path): +def test_to_mermaid_image_failing_request(tmp_path): pipe = Pipeline() pipe.add_component("comp1", Double()) pipe.add_component("comp2", Double()) pipe.connect("comp1", "comp2") pipe.connect("comp2", "comp1") - with patch("haystack.core.pipeline.draw.mermaid.requests.get") as mock_get: + with patch("haystack.core.pipeline.draw.requests.get") as mock_get: def raise_for_status(self): raise requests.HTTPError() @@ -47,21 +120,20 @@ def raise_for_status(self): mock_get.return_value = mock_response with pytest.raises(PipelineDrawingError, match="There was an issue with https://mermaid.ink/"): - _draw(pipe.graph, tmp_path / "test_pipe.jpg", engine="mermaid-image") + _to_mermaid_image(_prepare_for_drawing(pipe.graph)) @pytest.mark.integration -def test_draw_mermaid_text(tmp_path): +def test_to_mermaid_text(tmp_path): pipe = Pipeline() pipe.add_component("comp1", AddFixedValue(add=3)) pipe.add_component("comp2", Double()) pipe.connect("comp1.result", "comp2.value") pipe.connect("comp2.value", "comp1.value") - _draw(pipe.graph, tmp_path / "test_pipe.md", engine="mermaid-text") - assert os.path.exists(tmp_path / "test_pipe.md") + text = _to_mermaid_text(_prepare_for_drawing(pipe.graph)) assert ( - open(tmp_path / "test_pipe.md", "r").read() + text == """ %%{ init: {'theme': 'neutral' } }%% @@ -73,25 +145,3 @@ def test_draw_mermaid_text(tmp_path): classDef component text-align:center; """ ) - - -def test_draw_unknown_engine(tmp_path): - pipe = Pipeline() - pipe.add_component("comp1", Double()) - pipe.add_component("comp2", Double()) - pipe.connect("comp1", "comp2") - pipe.connect("comp2", "comp1") - - with pytest.raises(ValueError, match="Unknown rendering engine 'unknown'"): - _draw(pipe.graph, tmp_path / "test_pipe.jpg", engine="unknown") - - -def test_convert_unknown_engine(tmp_path): - pipe = Pipeline() - pipe.add_component("comp1", Double()) - pipe.add_component("comp2", Double()) - pipe.connect("comp1", "comp2") - pipe.connect("comp2", "comp1") - - with pytest.raises(ValueError, match="Unknown rendering engine 'unknown'"): - _convert(pipe.graph, engine="unknown") diff --git a/test/core/pipeline/test_draw_graphviz.py b/test/core/pipeline/test_draw_graphviz.py deleted file mode 100644 index 03c4197540..0000000000 --- a/test/core/pipeline/test_draw_graphviz.py +++ /dev/null @@ -1,26 +0,0 @@ -# SPDX-FileCopyrightText: 2022-present deepset GmbH -# -# SPDX-License-Identifier: Apache-2.0 -import os -import filecmp - -import pytest - -from haystack.core.pipeline import Pipeline -from haystack.core.pipeline.draw.draw import _draw -from haystack.testing.sample_components import Double - - -pygraphviz = pytest.importorskip("pygraphviz") - - -@pytest.mark.integration -def test_draw_pygraphviz(tmp_path, test_files): - pipe = Pipeline() - pipe.add_component("comp1", Double()) - pipe.add_component("comp2", Double()) - pipe.connect("comp1", "comp2") - - _draw(pipe.graph, tmp_path / "test_pipe.jpg", engine="graphviz") - assert os.path.exists(tmp_path / "test_pipe.jpg") - assert filecmp.cmp(tmp_path / "test_pipe.jpg", test_files / "pipeline_draw" / "pygraphviz.jpg") From 6290a183e386bb6f413d54c7e1f08911f6490a1a Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Fri, 9 Feb 2024 12:32:30 +0100 Subject: [PATCH 2/8] Add util method to check if we're in a Jupyter notebook --- haystack/utils/__init__.py | 24 +++++++++++++++++++----- haystack/utils/jupyter.py | 25 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 haystack/utils/jupyter.py diff --git a/haystack/utils/__init__.py b/haystack/utils/__init__.py index 9e0ae39000..9282a612fb 100644 --- a/haystack/utils/__init__.py +++ b/haystack/utils/__init__.py @@ -1,5 +1,19 @@ -from haystack.utils.expit import expit -from haystack.utils.requests_utils import request_with_retry -from haystack.utils.filters import document_matches_filter -from haystack.utils.device import ComponentDevice, DeviceType, Device, DeviceMap -from haystack.utils.auth import Secret, deserialize_secrets_inplace +from .auth import Secret, deserialize_secrets_inplace +from .device import ComponentDevice, Device, DeviceMap, DeviceType +from .expit import expit +from .filters import document_matches_filter +from .jupyter import is_in_jupyter +from .requests_utils import request_with_retry + +__all__ = [ + "Secret", + "deserialize_secrets_inplace", + "ComponentDevice", + "Device", + "DeviceMap", + "DeviceType", + "expit", + "document_matches_filter", + "is_in_jupyter", + "request_with_retry", +] diff --git a/haystack/utils/jupyter.py b/haystack/utils/jupyter.py new file mode 100644 index 0000000000..3c4ffdc6c3 --- /dev/null +++ b/haystack/utils/jupyter.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: 2022-present deepset GmbH +# +# SPDX-License-Identifier: Apache-2.0 + + +def is_in_jupyter() -> bool: + """ + Utility function to easily check if we are in a Jupyter or Google Colab environment. + + Inspired by: + https://github.com/explosion/spaCy/blob/e1249d3722765aaca56f538e830add7014d20e2a/spacy/util.py#L1079 + + Returns True if in Jupyter or Google Colab, False otherwise + """ + # + # + try: + # We don't need to import `get_ipython` as it's always present in Jupyter notebooks + if get_ipython().__class__.__name__ == "ZMQInteractiveShell": # type: ignore[name-defined] + return True # Jupyter notebook or qtconsole + if get_ipython().__class__.__module__ == "google.colab._shell": # type: ignore[name-defined] + return True # Colab notebook + except NameError: + pass # Probably standard Python interpreter + return False From b98b4e7199b53d91cf0aa7167c49145994da9f74 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Fri, 9 Feb 2024 12:34:05 +0100 Subject: [PATCH 3/8] Split Pipeline.draw() in two methods --- haystack/core/pipeline/draw.py | 39 +++--------------------------- haystack/core/pipeline/pipeline.py | 35 +++++++++++++++++++++------ 2 files changed, 31 insertions(+), 43 deletions(-) diff --git a/haystack/core/pipeline/draw.py b/haystack/core/pipeline/draw.py index 31c9c08eab..60aa6ac806 100644 --- a/haystack/core/pipeline/draw.py +++ b/haystack/core/pipeline/draw.py @@ -3,8 +3,6 @@ # SPDX-License-Identifier: Apache-2.0 import base64 import logging -from pathlib import Path -from typing import Optional import networkx # type:ignore import requests @@ -16,39 +14,6 @@ logger = logging.getLogger(__name__) -def _draw(graph: networkx.MultiDiGraph, path: Optional[Path] = None) -> None: - """ - Draw a pipeline graph using Mermaid and save it to a file. - If on a Jupyter notebook, it will also display the image inline. - """ - image_data = _to_mermaid_image(_prepare_for_drawing(graph)) - - in_notebook = False - try: - from IPython.core.getipython import get_ipython - from IPython.display import Image, display - - if "IPKernelApp" in get_ipython().config: - # We're in a notebook, let's display the image - display(Image(image_data)) - in_notebook = True - except ImportError: - pass - except AttributeError: - pass - - if not in_notebook and not path: - # We're not in a notebook and no path is given, the user must have forgot - # to specify the path. Raise an error. - msg = "No path specified to save the image to." - raise ValueError(msg) - - if path: - # If we reached this point we're in a notebook and the user has specified a path. - # Let's save the image anyway even if it's been displayed in the notebook. - Path(path).write_bytes(image_data) - - def _prepare_for_drawing(graph: networkx.MultiDiGraph) -> networkx.MultiDiGraph: """ Add some extra nodes to show the inputs and outputs of the pipeline. @@ -99,6 +64,8 @@ def _to_mermaid_image(graph: networkx.MultiDiGraph): """ Renders a pipeline using Mermaid (hosted version at 'https://mermaid.ink'). Requires Internet access. """ + # Copy the graph to avoid modifying the original + graph = _prepare_for_drawing(graph.copy()) graph_styled = _to_mermaid_text(graph=graph) graphbytes = graph_styled.encode("ascii") @@ -131,6 +98,8 @@ def _to_mermaid_text(graph: networkx.MultiDiGraph) -> str: Converts a Networkx graph into Mermaid syntax. The output of this function can be used in the documentation with `mermaid` codeblocks and it will be automatically rendered. """ + # Copy the graph to avoid modifying the original + graph = _prepare_for_drawing(graph.copy()) sockets = { comp: "".join( [ diff --git a/haystack/core/pipeline/pipeline.py b/haystack/core/pipeline/pipeline.py index c76b548aa9..632cc73ef6 100644 --- a/haystack/core/pipeline/pipeline.py +++ b/haystack/core/pipeline/pipeline.py @@ -11,12 +11,19 @@ import networkx # type:ignore from haystack.core.component import Component, InputSocket, OutputSocket, component -from haystack.core.errors import PipelineConnectError, PipelineError, PipelineRuntimeError, PipelineValidationError +from haystack.core.errors import ( + PipelineConnectError, + PipelineDrawingError, + PipelineError, + PipelineRuntimeError, + PipelineValidationError, +) from haystack.core.serialization import component_from_dict, component_to_dict from haystack.core.type_utils import _type_name, _types_are_compatible +from haystack.utils import is_in_jupyter from .descriptions import find_pipeline_inputs, find_pipeline_outputs -from .draw import _draw +from .draw import _to_mermaid_image logger = logging.getLogger(__name__) @@ -442,17 +449,29 @@ def outputs(self) -> Dict[str, Dict[str, Any]]: } return outputs - def draw(self, path: Optional[Path] = None) -> None: + def show(self) -> None: """ - Save a Pipeline image to `path`. - If `path` is `None` the image will be displayed inline in the Jupyter notebook. - If `path` is `None` and the code is not running in a Jupyter notebook, an error will be raised. + If running in a Jupyter notebook, display an image representing this `Pipeline`. - If `path` is given it will always be saved to file, whether it's a notebook or not. + """ + if is_in_jupyter(): + from IPython.display import Image, display + + image_data = _to_mermaid_image(self.graph) + + display(Image(image_data)) + else: + msg = "This method is only supported in Jupyter notebooks. Use Pipeline.draw() to save an image locally." + raise PipelineDrawingError(msg) + + def draw(self, path: Path) -> None: + """ + Save an image representing this `Pipeline` to `path`. """ # Before drawing we edit a bit the graph, to avoid modifying the original that is # used for running the pipeline we copy it. - _draw(graph=self.graph.copy(), path=path) + image_data = _to_mermaid_image(self.graph) + Path(path).write_bytes(image_data) def warm_up(self): """ From 21fa35b09130b12cfec15acfd47b3ccebbbd0215 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Fri, 9 Feb 2024 12:37:09 +0100 Subject: [PATCH 4/8] Update tests --- test/core/conftest.py | 13 -- test/core/pipeline/test_default_value.py | 6 +- .../pipeline/test_double_loop_pipeline.py | 4 +- test/core/pipeline/test_draw.py | 111 +++++------------- test/core/pipeline/test_pipeline.py | 38 +++++- .../test_files/mermaid_mock/test_response.png | Bin 2976 -> 0 bytes test/core/test_files/test_mermaid_graph.png | Bin 0 -> 8139 bytes 7 files changed, 70 insertions(+), 102 deletions(-) delete mode 100644 test/core/test_files/mermaid_mock/test_response.png create mode 100644 test/core/test_files/test_mermaid_graph.png diff --git a/test/core/conftest.py b/test/core/conftest.py index f4b2268767..4be63b2f2a 100644 --- a/test/core/conftest.py +++ b/test/core/conftest.py @@ -7,16 +7,3 @@ @pytest.fixture def test_files(): return Path(__file__).parent / "test_files" - - -@pytest.fixture(autouse=True) -def mock_mermaid_request(test_files): - """ - Prevents real requests to https://mermaid.ink/ - """ - with patch("haystack.core.pipeline.draw.requests.get") as mock_get: - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.content = open(test_files / "mermaid_mock" / "test_response.png", "rb").read() - mock_get.return_value = mock_response - yield diff --git a/test/core/pipeline/test_default_value.py b/test/core/pipeline/test_default_value.py index 8b37017f2a..18f2f93542 100644 --- a/test/core/pipeline/test_default_value.py +++ b/test/core/pipeline/test_default_value.py @@ -1,13 +1,12 @@ # SPDX-FileCopyrightText: 2022-present deepset GmbH # # SPDX-License-Identifier: Apache-2.0 +import logging from pathlib import Path from haystack.core.component import component from haystack.core.pipeline import Pipeline -import logging - logging.basicConfig(level=logging.DEBUG) @@ -18,10 +17,9 @@ def run(self, a: int, b: int = 2): return {"c": a + b} -def test_pipeline(tmp_path): +def test_pipeline(): pipeline = Pipeline() pipeline.add_component("with_defaults", WithDefault()) - pipeline.draw(tmp_path / "default_value.png") # Pass all the inputs results = pipeline.run({"with_defaults": {"a": 40, "b": 30}}) diff --git a/test/core/pipeline/test_double_loop_pipeline.py b/test/core/pipeline/test_double_loop_pipeline.py index 7791e62c8a..886e91ec12 100644 --- a/test/core/pipeline/test_double_loop_pipeline.py +++ b/test/core/pipeline/test_double_loop_pipeline.py @@ -10,7 +10,7 @@ logging.basicConfig(level=logging.DEBUG) -def test_pipeline(tmp_path): +def test_pipeline(): accumulator = Accumulate() pipeline = Pipeline(max_loops_allowed=10) @@ -31,8 +31,6 @@ def test_pipeline(tmp_path): pipeline.connect("add_three.result", "multiplexer") pipeline.connect("below_10.above", "add_two.value") - pipeline.draw(tmp_path / "double_loop_pipeline.png") - results = pipeline.run({"add_one": {"value": 3}}) assert results == {"add_two": {"result": 13}} diff --git a/test/core/pipeline/test_draw.py b/test/core/pipeline/test_draw.py index be8bca3444..6554f241cf 100644 --- a/test/core/pipeline/test_draw.py +++ b/test/core/pipeline/test_draw.py @@ -8,99 +8,37 @@ from haystack.core.errors import PipelineDrawingError from haystack.core.pipeline import Pipeline -from haystack.core.pipeline.draw import _draw, _prepare_for_drawing, _to_mermaid_image, _to_mermaid_text +from haystack.core.pipeline.draw import _to_mermaid_image, _to_mermaid_text from haystack.testing.sample_components import AddFixedValue, Double -@patch("haystack.core.pipeline.draw._to_mermaid_image") -def test_draw_does_not_edit_graph(mock_to_mermaid_image, tmp_path): - mock_to_mermaid_image.return_value = b"some_image_data" - - pipe = Pipeline() - pipe.add_component("comp1", Double()) - pipe.add_component("comp2", Double()) - pipe.connect("comp1", "comp2") - pipe.connect("comp2", "comp1") - - before_draw = pipe.to_dict() - image_path = tmp_path / "test.png" - pipe.draw(path=image_path) - - assert before_draw == pipe.to_dict() - - assert image_path.read_bytes() == mock_to_mermaid_image.return_value - - -@patch("haystack.core.pipeline.draw._to_mermaid_image") -@patch("IPython.core.getipython.get_ipython") -@patch("IPython.display.Image") -@patch("IPython.display.display") -def test_draw_display_in_notebook(mock_ipython_display, mock_ipython_image, mock_get_ipython, mock_to_mermaid_image): - pipe = Pipeline() - pipe.add_component("comp1", Double()) - pipe.add_component("comp2", Double()) - pipe.connect("comp1", "comp2") - pipe.connect("comp2", "comp1") - - mock_to_mermaid_image.return_value = b"some_image_data" - mock_get_ipython.return_value = MagicMock(config={"IPKernelApp": True}) - - _draw(pipe.graph) - mock_ipython_image.assert_called_once_with(b"some_image_data") - mock_ipython_display.assert_called_once() - - -@patch("haystack.core.pipeline.draw._to_mermaid_image") -@patch("IPython.core.getipython.get_ipython") -@patch("IPython.display.Image") -@patch("IPython.display.display") -def test_draw_display_in_notebook_saves_image( - mock_ipython_display, mock_ipython_image, mock_get_ipython, mock_to_mermaid_image, tmp_path -): - pipe = Pipeline() - pipe.add_component("comp1", Double()) - pipe.add_component("comp2", Double()) - pipe.connect("comp1", "comp2") - pipe.connect("comp2", "comp1") - - mock_to_mermaid_image.return_value = b"some_image_data" - mock_get_ipython.return_value = MagicMock(config={"IPKernelApp": True}) - - image_path = tmp_path / "test.png" - _draw(pipe.graph, path=image_path) - - assert image_path.read_bytes() == mock_to_mermaid_image.return_value - - -@patch("haystack.core.pipeline.draw._to_mermaid_image") -def test_draw_raises_if_no_path_not_in_notebook(mock_to_mermaid_image, tmp_path, monkeypatch): - # Simulate not being in a notebook - monkeypatch.delattr("IPython.core.getipython") - +@pytest.mark.integration +def test_to_mermaid_image(test_files): pipe = Pipeline() pipe.add_component("comp1", Double()) pipe.add_component("comp2", Double()) pipe.connect("comp1", "comp2") pipe.connect("comp2", "comp1") - with pytest.raises(ValueError): - _draw(pipe.graph) + image_data = _to_mermaid_image(pipe.graph) + test_image = test_files / "test_mermaid_graph.png" + assert test_image.read_bytes() == image_data -@pytest.mark.integration -def test_to_mermaid_image(test_files): +@patch("haystack.core.pipeline.draw.requests") +def test_to_mermaid_image_does_not_edit_graph(mock_requests): pipe = Pipeline() - pipe.add_component("comp1", Double()) + pipe.add_component("comp1", AddFixedValue(add=3)) pipe.add_component("comp2", Double()) - pipe.connect("comp1", "comp2") - pipe.connect("comp2", "comp1") + pipe.connect("comp1.result", "comp2.value") + pipe.connect("comp2.value", "comp1.value") - image_data = _to_mermaid_image(_prepare_for_drawing(pipe.graph)) - test_image = test_files / "mermaid_mock" / "test_response.png" - assert test_image.read_bytes() == image_data + mock_requests.get.return_value = MagicMock(status_code=200) + expected_pipe = pipe.to_dict() + _to_mermaid_image(pipe.graph) + assert expected_pipe == pipe.to_dict() -@pytest.mark.integration def test_to_mermaid_image_failing_request(tmp_path): pipe = Pipeline() pipe.add_component("comp1", Double()) @@ -120,18 +58,17 @@ def raise_for_status(self): mock_get.return_value = mock_response with pytest.raises(PipelineDrawingError, match="There was an issue with https://mermaid.ink/"): - _to_mermaid_image(_prepare_for_drawing(pipe.graph)) + _to_mermaid_image(pipe.graph) -@pytest.mark.integration -def test_to_mermaid_text(tmp_path): +def test_to_mermaid_text(): pipe = Pipeline() pipe.add_component("comp1", AddFixedValue(add=3)) pipe.add_component("comp2", Double()) pipe.connect("comp1.result", "comp2.value") pipe.connect("comp2.value", "comp1.value") - text = _to_mermaid_text(_prepare_for_drawing(pipe.graph)) + text = _to_mermaid_text(pipe.graph) assert ( text == """ @@ -145,3 +82,15 @@ def test_to_mermaid_text(tmp_path): classDef component text-align:center; """ ) + + +def test_to_mermaid_text_does_not_edit_graph(): + pipe = Pipeline() + pipe.add_component("comp1", AddFixedValue(add=3)) + pipe.add_component("comp2", Double()) + pipe.connect("comp1.result", "comp2.value") + pipe.connect("comp2.value", "comp1.value") + + expected_pipe = pipe.to_dict() + _to_mermaid_text(pipe.graph) + assert expected_pipe == pipe.to_dict() diff --git a/test/core/pipeline/test_pipeline.py b/test/core/pipeline/test_pipeline.py index 45f1c883da..c6dec13289 100644 --- a/test/core/pipeline/test_pipeline.py +++ b/test/core/pipeline/test_pipeline.py @@ -3,11 +3,12 @@ # SPDX-License-Identifier: Apache-2.0 import logging from typing import Optional +from unittest.mock import patch import pytest from haystack.core.component.types import InputSocket, OutputSocket -from haystack.core.errors import PipelineError, PipelineRuntimeError +from haystack.core.errors import PipelineDrawingError, PipelineError, PipelineRuntimeError from haystack.core.pipeline import Pipeline from haystack.testing.factory import component_class from haystack.testing.sample_components import AddFixedValue, Double @@ -15,6 +16,41 @@ logging.basicConfig(level=logging.DEBUG) +@patch("haystack.core.pipeline.pipeline._to_mermaid_image") +@patch("haystack.core.pipeline.pipeline.is_in_jupyter") +@patch("IPython.display.Image") +@patch("IPython.display.display") +def test_show_in_notebook(mock_ipython_display, mock_ipython_image, mock_is_in_jupyter, mock_to_mermaid_image): + pipe = Pipeline() + + mock_to_mermaid_image.return_value = b"some_image_data" + mock_is_in_jupyter.return_value = True + + pipe.show() + mock_ipython_image.assert_called_once_with(b"some_image_data") + mock_ipython_display.assert_called_once() + + +@patch("haystack.core.pipeline.pipeline.is_in_jupyter") +def test_show_not_in_notebook(mock_is_in_jupyter): + pipe = Pipeline() + + mock_is_in_jupyter.return_value = False + + with pytest.raises(PipelineDrawingError): + pipe.show() + + +@patch("haystack.core.pipeline.pipeline._to_mermaid_image") +def test_draw(mock_to_mermaid_image, tmp_path): + pipe = Pipeline() + mock_to_mermaid_image.return_value = b"some_image_data" + + image_path = tmp_path / "test.png" + pipe.draw(path=image_path) + assert image_path.read_bytes() == mock_to_mermaid_image.return_value + + def test_add_component_to_different_pipelines(): first_pipe = Pipeline() second_pipe = Pipeline() diff --git a/test/core/test_files/mermaid_mock/test_response.png b/test/core/test_files/mermaid_mock/test_response.png deleted file mode 100644 index 3bdd0db2f03ab15af365b777b506f3190509b2cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2976 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 F{|0Qu#Wnx{ diff --git a/test/core/test_files/test_mermaid_graph.png b/test/core/test_files/test_mermaid_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..359e59f625044a7681be2b9b2e7de8e5caf3cbd7 GIT binary patch literal 8139 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 Date: Fri, 9 Feb 2024 12:38:58 +0100 Subject: [PATCH 5/8] Update releasenotes --- .../notes/enhance-pipeline-draw-5fe3131db71f6f54.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/releasenotes/notes/enhance-pipeline-draw-5fe3131db71f6f54.yaml b/releasenotes/notes/enhance-pipeline-draw-5fe3131db71f6f54.yaml index 92d3264db6..8f586b90fb 100644 --- a/releasenotes/notes/enhance-pipeline-draw-5fe3131db71f6f54.yaml +++ b/releasenotes/notes/enhance-pipeline-draw-5fe3131db71f6f54.yaml @@ -1,6 +1,7 @@ --- enhancements: - | - `Pipeline.draw()` will now show the generated image inline if run in a Jupyter notebook. - It can also be called without a path to save the image if in a notebook. In all other cases - it will raise a `ValueError`. + Add new `Pipeline.show()` method to generated image inline if run in a Jupyter notebook. + If called outside a notebook it will raise a `PipelineDrawingError`. + `Pipeline.draw()` has also been simplified and the `engine` argument has been removed. + Now all images will be generated using Mermaid. From 0f8268446291b16f1428173e6d0ead5311a3fea5 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Thu, 8 Feb 2024 18:29:05 +0100 Subject: [PATCH 6/8] Enhance Pipeline.__repr__ --- haystack/core/pipeline/pipeline.py | 31 ++++++++++++++ .../notes/enhance-repr-0c5efa1e2ca6bafa.yaml | 5 +++ test/core/pipeline/test_pipeline.py | 42 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 releasenotes/notes/enhance-repr-0c5efa1e2ca6bafa.yaml diff --git a/haystack/core/pipeline/pipeline.py b/haystack/core/pipeline/pipeline.py index 632cc73ef6..80b765abfc 100644 --- a/haystack/core/pipeline/pipeline.py +++ b/haystack/core/pipeline/pipeline.py @@ -71,6 +71,37 @@ def __eq__(self, other) -> bool: return False return self.to_dict() == other.to_dict() + def __repr__(self) -> str: + """ + Returns a text representation of the Pipeline. + If this runs in a Jupyter notebook, it will instead display the Pipeline image. + """ + res = "" + try: + # We call draw here so that if we're in a Jupyter notebook we can embed the Pipeline image. + # If it fails it's no a big deal, we just don't show the image. Also it probably means we're not in a + # Jupyter notebook. + self.draw() + except ValueError: + # Let's show the text repr only if we're not in a Jupyter notebook + res += f"{object.__repr__(self)}\n" + if self.metadata: + res += "🧱 Metadata\n" + for k, v in self.metadata.items(): + res += f" - {k}: {v}\n" + + res += "🚅 Components\n" + for name, instance in self.graph.nodes(data="instance"): + res += f" - {name}: {instance.__class__.__name__}\n" + + res += "🛤️ Connections\n" + for sender, receiver, edge_data in self.graph.edges(data=True): + sender_socket = edge_data["from_socket"].name + receiver_socket = edge_data["to_socket"].name + res += f" - {sender}.{sender_socket} -> {receiver}.{receiver_socket} ({edge_data['conn_type']})\n" + + return res + def to_dict(self) -> Dict[str, Any]: """ Returns this Pipeline instance as a dictionary. diff --git a/releasenotes/notes/enhance-repr-0c5efa1e2ca6bafa.yaml b/releasenotes/notes/enhance-repr-0c5efa1e2ca6bafa.yaml new file mode 100644 index 0000000000..b8e66dac35 --- /dev/null +++ b/releasenotes/notes/enhance-repr-0c5efa1e2ca6bafa.yaml @@ -0,0 +1,5 @@ +--- +enhancements: + - | + Customize `Pipeline.__repr__()` to return a nice text representation of it. + If run on a Jupyter notebook it will instead embed the image created with `Pipeline.draw()`. diff --git a/test/core/pipeline/test_pipeline.py b/test/core/pipeline/test_pipeline.py index c6dec13289..c007f87118 100644 --- a/test/core/pipeline/test_pipeline.py +++ b/test/core/pipeline/test_pipeline.py @@ -79,6 +79,48 @@ def test_get_component_name_not_added_to_pipeline(): assert pipe.get_component_name(some_component) == "" +@patch.object(Pipeline, "draw") +def test_repr(mock_draw): + # Simulate not being in a notebook + mock_draw.side_effect = ValueError + + pipe = Pipeline(metadata={"test": "test"}, max_loops_allowed=42) + pipe.add_component("add_two", AddFixedValue(add=2)) + pipe.add_component("add_default", AddFixedValue()) + pipe.add_component("double", Double()) + pipe.connect("add_two", "double") + pipe.connect("double", "add_default") + + expected_repr = ( + f"{object.__repr__(pipe)}\n" + "🧱 Metadata\n" + " - test: test\n" + "🚅 Components\n" + " - add_two: AddFixedValue\n" + " - add_default: AddFixedValue\n" + " - double: Double\n" + "🛤️ Connections\n" + " - add_two.result -> double.value (int)\n" + " - double.value -> add_default.value (int)\n" + ) + assert repr(pipe) == expected_repr + + mock_draw.assert_called_once_with() + + +def test_repr_on_notebook(): + pipe = Pipeline(metadata={"test": "test"}, max_loops_allowed=42) + pipe.add_component("add_two", AddFixedValue(add=2)) + pipe.add_component("add_default", AddFixedValue()) + pipe.add_component("double", Double()) + pipe.connect("add_two", "double") + pipe.connect("double", "add_default") + + with patch.object(Pipeline, "draw") as mock_draw: + assert repr(pipe) == "" + mock_draw.assert_called_once_with() + + def test_run_with_component_that_does_not_return_dict(): BrokenComponent = component_class( "BrokenComponent", input_types={"a": int}, output_types={"b": int}, output=1 # type:ignore From 2d87831e3cbcada313378c6cad4139dee04ca10d Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Fri, 9 Feb 2024 12:47:00 +0100 Subject: [PATCH 7/8] Simplify Pipeline.__repr__ --- haystack/core/pipeline/pipeline.py | 43 ++++++++++++++--------------- test/core/pipeline/test_pipeline.py | 21 +++++++------- 2 files changed, 31 insertions(+), 33 deletions(-) diff --git a/haystack/core/pipeline/pipeline.py b/haystack/core/pipeline/pipeline.py index 80b765abfc..98ba8df871 100644 --- a/haystack/core/pipeline/pipeline.py +++ b/haystack/core/pipeline/pipeline.py @@ -76,29 +76,26 @@ def __repr__(self) -> str: Returns a text representation of the Pipeline. If this runs in a Jupyter notebook, it will instead display the Pipeline image. """ - res = "" - try: - # We call draw here so that if we're in a Jupyter notebook we can embed the Pipeline image. - # If it fails it's no a big deal, we just don't show the image. Also it probably means we're not in a - # Jupyter notebook. - self.draw() - except ValueError: - # Let's show the text repr only if we're not in a Jupyter notebook - res += f"{object.__repr__(self)}\n" - if self.metadata: - res += "🧱 Metadata\n" - for k, v in self.metadata.items(): - res += f" - {k}: {v}\n" - - res += "🚅 Components\n" - for name, instance in self.graph.nodes(data="instance"): - res += f" - {name}: {instance.__class__.__name__}\n" - - res += "🛤️ Connections\n" - for sender, receiver, edge_data in self.graph.edges(data=True): - sender_socket = edge_data["from_socket"].name - receiver_socket = edge_data["to_socket"].name - res += f" - {sender}.{sender_socket} -> {receiver}.{receiver_socket} ({edge_data['conn_type']})\n" + if is_in_jupyter(): + # If we're in a Jupyter notebook we want to display the image instead of the text repr. + self.show() + return "" + + res = f"{object.__repr__(self)}\n" + if self.metadata: + res += "🧱 Metadata\n" + for k, v in self.metadata.items(): + res += f" - {k}: {v}\n" + + res += "🚅 Components\n" + for name, instance in self.graph.nodes(data="instance"): + res += f" - {name}: {instance.__class__.__name__}\n" + + res += "🛤️ Connections\n" + for sender, receiver, edge_data in self.graph.edges(data=True): + sender_socket = edge_data["from_socket"].name + receiver_socket = edge_data["to_socket"].name + res += f" - {sender}.{sender_socket} -> {receiver}.{receiver_socket} ({edge_data['conn_type']})\n" return res diff --git a/test/core/pipeline/test_pipeline.py b/test/core/pipeline/test_pipeline.py index c007f87118..4e66f38f17 100644 --- a/test/core/pipeline/test_pipeline.py +++ b/test/core/pipeline/test_pipeline.py @@ -79,11 +79,8 @@ def test_get_component_name_not_added_to_pipeline(): assert pipe.get_component_name(some_component) == "" -@patch.object(Pipeline, "draw") -def test_repr(mock_draw): - # Simulate not being in a notebook - mock_draw.side_effect = ValueError - +@patch("haystack.core.pipeline.pipeline.is_in_jupyter") +def test_repr(mock_is_in_jupyter): pipe = Pipeline(metadata={"test": "test"}, max_loops_allowed=42) pipe.add_component("add_two", AddFixedValue(add=2)) pipe.add_component("add_default", AddFixedValue()) @@ -103,12 +100,13 @@ def test_repr(mock_draw): " - add_two.result -> double.value (int)\n" " - double.value -> add_default.value (int)\n" ) + # Simulate not being in a notebook + mock_is_in_jupyter.return_value = False assert repr(pipe) == expected_repr - mock_draw.assert_called_once_with() - -def test_repr_on_notebook(): +@patch("haystack.core.pipeline.pipeline.is_in_jupyter") +def test_repr_in_notebook(mock_is_in_jupyter): pipe = Pipeline(metadata={"test": "test"}, max_loops_allowed=42) pipe.add_component("add_two", AddFixedValue(add=2)) pipe.add_component("add_default", AddFixedValue()) @@ -116,9 +114,12 @@ def test_repr_on_notebook(): pipe.connect("add_two", "double") pipe.connect("double", "add_default") - with patch.object(Pipeline, "draw") as mock_draw: + # Simulate being in a notebook + mock_is_in_jupyter.return_value = True + + with patch.object(Pipeline, "show") as mock_show: assert repr(pipe) == "" - mock_draw.assert_called_once_with() + mock_show.assert_called_once_with() def test_run_with_component_that_does_not_return_dict(): From 40eaaeff47fdd6088d013c685f786c7a4a45083c Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Fri, 9 Feb 2024 12:50:40 +0100 Subject: [PATCH 8/8] Update release notes --- releasenotes/notes/enhance-repr-0c5efa1e2ca6bafa.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/releasenotes/notes/enhance-repr-0c5efa1e2ca6bafa.yaml b/releasenotes/notes/enhance-repr-0c5efa1e2ca6bafa.yaml index b8e66dac35..a9f1914efc 100644 --- a/releasenotes/notes/enhance-repr-0c5efa1e2ca6bafa.yaml +++ b/releasenotes/notes/enhance-repr-0c5efa1e2ca6bafa.yaml @@ -2,4 +2,4 @@ enhancements: - | Customize `Pipeline.__repr__()` to return a nice text representation of it. - If run on a Jupyter notebook it will instead embed the image created with `Pipeline.draw()`. + If run on a Jupyter notebook it will instead have the same behaviour as `Pipeline.show()`.