chore: import upstream snapshot with attribution
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import base64
|
||||
import colorsys
|
||||
import json
|
||||
import random
|
||||
import zlib
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import networkx
|
||||
|
||||
from haystack import logging
|
||||
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 generate_color_variations(n: int, base_color: str | None = "#3498DB", variation_range: float = 0.4) -> list[str]:
|
||||
"""
|
||||
Generate n different variations of a base color.
|
||||
|
||||
:param n: Number of variations to generate
|
||||
:param base_color: Hex color code, default is a shade of blue (#3498DB)
|
||||
:param variation_range: Range for varying brightness and saturation (0-1)
|
||||
|
||||
:returns:
|
||||
list: List of hex color codes representing variations of the base color
|
||||
"""
|
||||
# convert hex to RGB
|
||||
base_color = base_color.lstrip("#") # type:ignore
|
||||
r = int(base_color[0:2], 16) / 255.0
|
||||
g = int(base_color[2:4], 16) / 255.0
|
||||
b = int(base_color[4:6], 16) / 255.0
|
||||
|
||||
# convert RGB to HSV (Hue, Saturation, Value)
|
||||
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
||||
|
||||
variations = []
|
||||
for _ in range(n):
|
||||
# vary saturation and brightness within the specified range
|
||||
new_s = max(0, min(1, s + random.uniform(-variation_range, variation_range)))
|
||||
new_v = max(0, min(1, v + random.uniform(-variation_range, variation_range)))
|
||||
|
||||
# keep hue the same for color consistency
|
||||
new_h = h
|
||||
|
||||
# Convert back to RGB and then to hex
|
||||
new_r, new_g, new_b = colorsys.hsv_to_rgb(new_h, new_s, new_v)
|
||||
hex_color = f"#{int(new_r * 255):02x}{int(new_g * 255):02x}{int(new_b * 255):02x}"
|
||||
|
||||
variations.append(hex_color)
|
||||
|
||||
return variations
|
||||
|
||||
|
||||
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 = "-->"
|
||||
ARROWHEAD_OPTIONAL = ".->"
|
||||
MERMAID_STYLED_TEMPLATE = """
|
||||
%%{{ init: {params} }}%%
|
||||
|
||||
graph TD;
|
||||
|
||||
{connections}
|
||||
|
||||
classDef component text-align:center;
|
||||
{style_definitions}
|
||||
"""
|
||||
|
||||
|
||||
def _validate_mermaid_params(params: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validates and sets default values for Mermaid parameters.
|
||||
|
||||
:param params:
|
||||
Dictionary of customization parameters to modify the output. Refer to Mermaid documentation for more details.
|
||||
Supported keys:
|
||||
- format: Output format ('img', 'svg', or 'pdf'). Default: 'img'.
|
||||
- type: Image type for /img endpoint ('jpeg', 'png', 'webp'). Default: 'png'.
|
||||
- theme: Mermaid theme ('default', 'neutral', 'dark', 'forest'). Default: 'neutral'.
|
||||
- bgColor: Background color in hexadecimal (e.g., 'FFFFFF') or named format (e.g., '!white').
|
||||
- width: Width of the output image (integer).
|
||||
- height: Height of the output image (integer).
|
||||
- scale: Scaling factor (1–3). Only applicable if 'width' or 'height' is specified.
|
||||
- fit: Whether to fit the diagram size to the page (PDF only, boolean).
|
||||
- paper: Paper size for PDFs (e.g., 'a4', 'a3'). Ignored if 'fit' is true.
|
||||
- landscape: Landscape orientation for PDFs (boolean). Ignored if 'fit' is true.
|
||||
|
||||
:raises ValueError:
|
||||
If any parameter is invalid or does not match the expected format.
|
||||
"""
|
||||
valid_img_types = {"jpeg", "png", "webp"}
|
||||
valid_themes = {"default", "neutral", "dark", "forest"}
|
||||
valid_formats = {"img", "svg", "pdf"}
|
||||
|
||||
params.setdefault("format", "img")
|
||||
params.setdefault("type", "png")
|
||||
params.setdefault("theme", "neutral")
|
||||
|
||||
if params["format"] not in valid_formats:
|
||||
raise ValueError(f"Invalid image format: {params['format']}. Valid options are: {valid_formats}.")
|
||||
|
||||
if params["format"] == "img" and params["type"] not in valid_img_types:
|
||||
raise ValueError(f"Invalid image type: {params['type']}. Valid options are: {valid_img_types}.")
|
||||
|
||||
if params["theme"] not in valid_themes:
|
||||
raise ValueError(f"Invalid theme: {params['theme']}. Valid options are: {valid_themes}.")
|
||||
|
||||
if "width" in params and not isinstance(params["width"], int):
|
||||
raise ValueError("Width must be an integer.")
|
||||
if "height" in params and not isinstance(params["height"], int):
|
||||
raise ValueError("Height must be an integer.")
|
||||
|
||||
if "scale" in params and not 1 <= params["scale"] <= 3:
|
||||
raise ValueError("Scale must be a number between 1 and 3.")
|
||||
if "scale" in params and not ("width" in params or "height" in params):
|
||||
raise ValueError("Scale is only allowed when width or height is set.")
|
||||
|
||||
if "bgColor" in params and not isinstance(params["bgColor"], str):
|
||||
raise ValueError("Background color must be a string.")
|
||||
|
||||
# PDF specific parameters
|
||||
if params["format"] == "pdf":
|
||||
if "fit" in params and not isinstance(params["fit"], bool):
|
||||
raise ValueError("Fit must be a boolean.")
|
||||
if "paper" in params and not isinstance(params["paper"], str):
|
||||
raise ValueError("Paper size must be a string (e.g., 'a4', 'a3').")
|
||||
if "landscape" in params and not isinstance(params["landscape"], bool):
|
||||
raise ValueError("Landscape must be a boolean.")
|
||||
if "fit" in params and ("paper" in params or "landscape" in params):
|
||||
logger.warning("`fit` overrides `paper` and `landscape` for PDFs. Ignoring `paper` and `landscape`.")
|
||||
|
||||
|
||||
# Magic-byte signatures used to verify a Mermaid server response matches the requested output format.
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
_JPEG_SIGNATURE = b"\xff\xd8\xff"
|
||||
_PDF_SIGNATURE = b"%PDF-"
|
||||
_RIFF_SIGNATURE = b"RIFF"
|
||||
_WEBP_SIGNATURE = b"WEBP"
|
||||
_SVG_PREFIXES = (b"<?xml", b"<svg")
|
||||
|
||||
|
||||
def _validate_image_response(resp: httpx.Response, params: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate that the Mermaid server response actually contains the expected image/SVG/PDF data.
|
||||
|
||||
`Pipeline.draw()` writes the raw response body to disk, so a misconfigured or malicious
|
||||
`server_url` could otherwise cause arbitrary content (e.g. an HTML error page or a crafted
|
||||
payload) to be written verbatim to the output path. As defense-in-depth we check both the
|
||||
`Content-Type` header (which the server controls and could spoof) and the response body's
|
||||
magic-byte signature (which is harder to forge while still producing a usable payload).
|
||||
|
||||
:param resp:
|
||||
The HTTP response returned by the Mermaid server.
|
||||
:param params:
|
||||
Validated Mermaid parameters; used to determine the expected output format.
|
||||
:raises PipelineDrawingError:
|
||||
If the response is empty or does not match the expected format.
|
||||
"""
|
||||
content = resp.content
|
||||
if not content:
|
||||
raise PipelineDrawingError("The Mermaid server returned an empty response; no image will be saved.")
|
||||
|
||||
output_format = params.get("format", "img")
|
||||
img_type = params.get("type", "png")
|
||||
|
||||
# (human-readable label, expected Content-Type prefix, body signature check)
|
||||
content_type_prefixes: tuple[str, ...]
|
||||
if output_format == "svg":
|
||||
expected_label = "SVG"
|
||||
content_type_prefixes = ("image/svg+xml", "text/xml", "application/xml")
|
||||
stripped = content.lstrip()[:512].lower()
|
||||
body_ok = stripped.startswith(_SVG_PREFIXES) or _SVG_PREFIXES[1] in stripped
|
||||
elif output_format == "pdf":
|
||||
expected_label = "PDF"
|
||||
content_type_prefixes = ("application/pdf",)
|
||||
body_ok = content.startswith(_PDF_SIGNATURE)
|
||||
elif img_type == "jpeg":
|
||||
expected_label = "JPEG image"
|
||||
content_type_prefixes = ("image/jpeg",)
|
||||
body_ok = content.startswith(_JPEG_SIGNATURE)
|
||||
elif img_type == "webp":
|
||||
expected_label = "WebP image"
|
||||
content_type_prefixes = ("image/webp",)
|
||||
body_ok = content[0:4] == _RIFF_SIGNATURE and content[8:12] == _WEBP_SIGNATURE
|
||||
else: # png (default)
|
||||
expected_label = "PNG image"
|
||||
content_type_prefixes = ("image/png",)
|
||||
body_ok = content.startswith(_PNG_SIGNATURE)
|
||||
|
||||
# The Content-Type header is server-controlled, so a mismatch is only a warning: the
|
||||
# authoritative check is the body signature below.
|
||||
content_type = resp.headers.get("content-type", "").split(";")[0].strip().lower()
|
||||
if content_type and not content_type.startswith(content_type_prefixes):
|
||||
logger.warning(
|
||||
"The Mermaid server returned an unexpected Content-Type '{content_type}' (expected {expected}).",
|
||||
content_type=content_type,
|
||||
expected=expected_label,
|
||||
)
|
||||
|
||||
if not body_ok:
|
||||
raise PipelineDrawingError(
|
||||
f"The Mermaid server response does not look like a valid {expected_label}. "
|
||||
f"This can happen if 'server_url' points to a server that is not a Mermaid renderer. "
|
||||
f"To avoid writing untrusted content to disk, no file will be saved."
|
||||
)
|
||||
|
||||
|
||||
def _to_mermaid_image(
|
||||
graph: networkx.MultiDiGraph,
|
||||
server_url: str = "https://mermaid.ink",
|
||||
params: dict | None = None,
|
||||
timeout: int = 30,
|
||||
super_component_mapping: dict[str, str] | None = None,
|
||||
) -> bytes:
|
||||
"""
|
||||
Renders a pipeline using a Mermaid server.
|
||||
|
||||
:param graph:
|
||||
The graph to render as a Mermaid pipeline.
|
||||
:param server_url:
|
||||
Base URL of the Mermaid server (default: 'https://mermaid.ink').
|
||||
:param params:
|
||||
Dictionary of customization parameters. See `validate_mermaid_params` for valid keys.
|
||||
:param timeout:
|
||||
Timeout in seconds for the request to the Mermaid server.
|
||||
:returns:
|
||||
The image, SVG, or PDF data returned by the Mermaid server as bytes.
|
||||
:raises ValueError:
|
||||
If any parameter is invalid or does not match the expected format.
|
||||
:raises PipelineDrawingError:
|
||||
If there is an issue connecting to the Mermaid server or the server returns an error.
|
||||
"""
|
||||
|
||||
if params is None:
|
||||
params = {}
|
||||
|
||||
_validate_mermaid_params(params)
|
||||
|
||||
theme = params.get("theme")
|
||||
init_params = json.dumps({"theme": theme})
|
||||
|
||||
# Copy the graph to avoid modifying the original
|
||||
graph_styled = _to_mermaid_text(graph.copy(), init_params, super_component_mapping)
|
||||
json_string = json.dumps({"code": graph_styled})
|
||||
|
||||
# Compress the JSON string with zlib (RFC 1950)
|
||||
compressor = zlib.compressobj(level=9, wbits=15)
|
||||
compressed_data = compressor.compress(json_string.encode("utf-8")) + compressor.flush()
|
||||
compressed_url_safe_base64 = base64.urlsafe_b64encode(compressed_data).decode("utf-8").strip()
|
||||
|
||||
# Determine the correct endpoint
|
||||
endpoint_format = params.get("format", "img") # Default to /img endpoint
|
||||
if endpoint_format not in {"img", "svg", "pdf"}:
|
||||
raise ValueError(f"Invalid format: {endpoint_format}. Valid options are 'img', 'svg', or 'pdf'.")
|
||||
|
||||
# Construct the URL without query parameters
|
||||
url = f"{server_url}/{endpoint_format}/pako:{compressed_url_safe_base64}"
|
||||
|
||||
# Add query parameters adhering to mermaid.ink documentation
|
||||
query_params = []
|
||||
for key, value in params.items():
|
||||
if key not in {"theme", "format"}: # Exclude theme (handled in init_params) and format (endpoint-specific)
|
||||
if value is True:
|
||||
query_params.append(f"{key}")
|
||||
else:
|
||||
query_params.append(f"{key}={value}")
|
||||
|
||||
if query_params:
|
||||
url += "?" + "&".join(query_params)
|
||||
|
||||
logger.debug("Rendering graph at {url}", url=url)
|
||||
try:
|
||||
resp = httpx.get(url, timeout=timeout)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"Failed to draw the pipeline: {server_url} returned status {status_code}",
|
||||
server_url=server_url,
|
||||
status_code=resp.status_code,
|
||||
)
|
||||
logger.info("Exact URL requested: {url}", url=url)
|
||||
logger.warning("No pipeline diagram will be saved.")
|
||||
resp.raise_for_status()
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to draw the pipeline: could not connect to {server_url} ({error})", server_url=server_url, error=exc
|
||||
)
|
||||
logger.info("Exact URL requested: {url}", url=url)
|
||||
logger.warning("No pipeline diagram will be saved.")
|
||||
raise PipelineDrawingError(f"There was an issue with {server_url}, see the stacktrace for details.") from exc
|
||||
|
||||
# Validate the response before it gets written to disk by the caller, so that a misconfigured
|
||||
# or malicious server cannot cause arbitrary content to be saved to the output path.
|
||||
_validate_image_response(resp, params)
|
||||
|
||||
return resp.content
|
||||
|
||||
|
||||
def _to_mermaid_text(
|
||||
graph: networkx.MultiDiGraph, init_params: str | dict, super_component_mapping: dict[str, str] | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Converts a Networkx graph into Mermaid syntax.
|
||||
|
||||
The output of this function can be used in the documentation with `mermaid` codeblocks and will be
|
||||
automatically rendered.
|
||||
|
||||
:param graph: The graph to convert to Mermaid syntax
|
||||
:param init_params: Initialization parameters for Mermaid
|
||||
:param super_component_mapping: Mapping of component names to super component names
|
||||
"""
|
||||
# Copy the graph to avoid modifying the original
|
||||
graph = _prepare_for_drawing(graph.copy())
|
||||
sockets = {
|
||||
comp: "".join(
|
||||
[
|
||||
f"<li>{name} ({_type_name(socket.type)})</li>"
|
||||
for name, socket in data.get("input_sockets", {}).items()
|
||||
if (not socket.is_mandatory and not socket.senders) or socket.is_variadic
|
||||
]
|
||||
)
|
||||
for comp, data in graph.nodes(data=True)
|
||||
}
|
||||
optional_inputs = {
|
||||
comp: f"<br><br>Optional inputs:<ul style='text-align:left;'>{sockets}</ul>" if sockets else ""
|
||||
for comp, sockets in sockets.items()
|
||||
}
|
||||
|
||||
# Create node definitions
|
||||
states = {}
|
||||
super_component_components = super_component_mapping.keys() if super_component_mapping else {}
|
||||
|
||||
# color variations for super components
|
||||
super_component_colors = {}
|
||||
if super_component_components:
|
||||
unique_super_components = set(super_component_mapping.values()) # type:ignore
|
||||
color_variations = generate_color_variations(n=len(unique_super_components))
|
||||
super_component_colors = dict(zip(unique_super_components, color_variations, strict=True))
|
||||
|
||||
# Generate style definitions for each super component
|
||||
style_definitions = []
|
||||
for super_comp, color in super_component_colors.items():
|
||||
style_definitions.append(f"classDef {super_comp} fill:{color},color:white;")
|
||||
|
||||
for comp, data in graph.nodes(data=True):
|
||||
if comp in ["input", "output"]:
|
||||
continue
|
||||
|
||||
# styling based on whether the component is a SuperComponent
|
||||
if comp in super_component_components:
|
||||
super_component_name = super_component_mapping[comp] # type:ignore
|
||||
style = super_component_name
|
||||
else:
|
||||
style = "component"
|
||||
node_def = f'{comp}["<b>{comp}</b><br><small><i>{type(data["instance"]).__name__}{optional_inputs[comp]}</i></small>"]:::{style}' # noqa: E501
|
||||
states[comp] = node_def
|
||||
|
||||
connections_list = []
|
||||
for from_comp, to_comp, conn_data in graph.edges(data=True):
|
||||
if from_comp != "input" and to_comp != "output":
|
||||
arrowtail = ARROWTAIL_MANDATORY if conn_data["mandatory"] else ARROWTAIL_OPTIONAL
|
||||
arrowhead = ARROWHEAD_MANDATORY if conn_data["mandatory"] else ARROWHEAD_OPTIONAL
|
||||
label = f'"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"'
|
||||
conn_string = f"{states[from_comp]} {arrowtail} {label} {arrowhead} {states[to_comp]}"
|
||||
connections_list.append(conn_string)
|
||||
|
||||
input_connections = [
|
||||
f'i{{*}}--"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"--> {states[to_comp]}'
|
||||
for _, to_comp, conn_data in graph.out_edges("input", data=True)
|
||||
]
|
||||
output_connections = [
|
||||
f'{states[from_comp]}--"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"--> o{{*}}'
|
||||
for from_comp, _, conn_data in graph.in_edges("output", data=True)
|
||||
]
|
||||
connections = "\n".join(connections_list + input_connections + output_connections)
|
||||
|
||||
# Create legend
|
||||
legend_nodes = []
|
||||
if super_component_colors:
|
||||
legend_nodes.append("subgraph Legend")
|
||||
for super_comp in super_component_colors:
|
||||
legend_id = f"legend_{super_comp}"
|
||||
legend_nodes.append(f'{legend_id}["{super_comp}"]:::{super_comp}')
|
||||
legend_nodes.append("end")
|
||||
connections += "\n" + "\n".join(legend_nodes)
|
||||
|
||||
# Add style definitions to the template
|
||||
graph_styled = MERMAID_STYLED_TEMPLATE.format(
|
||||
params=init_params, connections=connections, style_definitions="\n".join(style_definitions)
|
||||
)
|
||||
logger.debug("Mermaid diagram:\n{diagram}", diagram=graph_styled)
|
||||
|
||||
return graph_styled
|
||||
Reference in New Issue
Block a user