Coverage for haystack/core/pipeline/draw.py: 79%

199 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import base64 

6import colorsys 

7import json 

8import random 

9import zlib 

10from typing import Any 

11 

12import httpx 

13import networkx 

14 

15from haystack import logging 

16from haystack.core.errors import PipelineDrawingError 

17from haystack.core.pipeline.descriptions import find_pipeline_inputs, find_pipeline_outputs 

18from haystack.core.type_utils import _type_name 

19 

20logger = logging.getLogger(__name__) 

21 

22 

23def generate_color_variations(n: int, base_color: str | None = "#3498DB", variation_range: float = 0.4) -> list[str]: 

24 """ 

25 Generate n different variations of a base color. 

26 

27 :param n: Number of variations to generate 

28 :param base_color: Hex color code, default is a shade of blue (#3498DB) 

29 :param variation_range: Range for varying brightness and saturation (0-1) 

30 

31 :returns: 

32 list: List of hex color codes representing variations of the base color 

33 """ 

34 # convert hex to RGB 

35 base_color = base_color.lstrip("#") # type:ignore 

36 r = int(base_color[0:2], 16) / 255.0 

37 g = int(base_color[2:4], 16) / 255.0 

38 b = int(base_color[4:6], 16) / 255.0 

39 

40 # convert RGB to HSV (Hue, Saturation, Value) 

41 h, s, v = colorsys.rgb_to_hsv(r, g, b) 

42 

43 variations = [] 

44 for _ in range(n): 

45 # vary saturation and brightness within the specified range 

46 new_s = max(0, min(1, s + random.uniform(-variation_range, variation_range))) 

47 new_v = max(0, min(1, v + random.uniform(-variation_range, variation_range))) 

48 

49 # keep hue the same for color consistency 

50 new_h = h 

51 

52 # Convert back to RGB and then to hex 

53 new_r, new_g, new_b = colorsys.hsv_to_rgb(new_h, new_s, new_v) 

54 hex_color = f"#{int(new_r * 255):02x}{int(new_g * 255):02x}{int(new_b * 255):02x}" 

55 

56 variations.append(hex_color) 

57 

58 return variations 

59 

60 

61def _prepare_for_drawing(graph: networkx.MultiDiGraph) -> networkx.MultiDiGraph: 

62 """ 

63 Add some extra nodes to show the inputs and outputs of the pipeline. 

64 

65 Also adds labels to edges. 

66 """ 

67 # Label the edges 

68 for inp, outp, key, data in graph.edges(keys=True, data=True): 

69 data["label"] = ( 

70 f"{data['from_socket'].name} -> {data['to_socket'].name}{' (opt.)' if not data['mandatory'] else ''}" 

71 ) 

72 graph.add_edge(inp, outp, key=key, **data) 

73 

74 # Add inputs fake node 

75 graph.add_node("input") 

76 for node, in_sockets in find_pipeline_inputs(graph).items(): 

77 for in_socket in in_sockets: 

78 if not in_socket.senders and in_socket.is_mandatory: 

79 # If this socket has no sender it could be a socket that receives input 

80 # directly when running the Pipeline. We can't know that for sure, in doubt 

81 # we draw it as receiving input directly. 

82 graph.add_edge("input", node, label=in_socket.name, conn_type=_type_name(in_socket.type)) 

83 

84 # Add outputs fake node 

85 graph.add_node("output") 

86 for node, out_sockets in find_pipeline_outputs(graph).items(): 

87 for out_socket in out_sockets: 

88 graph.add_edge(node, "output", label=out_socket.name, conn_type=_type_name(out_socket.type)) 

89 

90 return graph 

91 

92 

93ARROWTAIL_MANDATORY = "--" 

94ARROWTAIL_OPTIONAL = "-." 

95ARROWHEAD_MANDATORY = "-->" 

96ARROWHEAD_OPTIONAL = ".->" 

97MERMAID_STYLED_TEMPLATE = """ 

98%%{{ init: {params} }}%% 

99 

100graph TD; 

101 

102{connections} 

103 

104classDef component text-align:center; 

105{style_definitions} 

106""" 

107 

108 

109def _validate_mermaid_params(params: dict[str, Any]) -> None: 

110 """ 

111 Validates and sets default values for Mermaid parameters. 

112 

113 :param params: 

114 Dictionary of customization parameters to modify the output. Refer to Mermaid documentation for more details. 

115 Supported keys: 

116 - format: Output format ('img', 'svg', or 'pdf'). Default: 'img'. 

117 - type: Image type for /img endpoint ('jpeg', 'png', 'webp'). Default: 'png'. 

118 - theme: Mermaid theme ('default', 'neutral', 'dark', 'forest'). Default: 'neutral'. 

119 - bgColor: Background color in hexadecimal (e.g., 'FFFFFF') or named format (e.g., '!white'). 

120 - width: Width of the output image (integer). 

121 - height: Height of the output image (integer). 

122 - scale: Scaling factor (1–3). Only applicable if 'width' or 'height' is specified. 

123 - fit: Whether to fit the diagram size to the page (PDF only, boolean). 

124 - paper: Paper size for PDFs (e.g., 'a4', 'a3'). Ignored if 'fit' is true. 

125 - landscape: Landscape orientation for PDFs (boolean). Ignored if 'fit' is true. 

126 

127 :raises ValueError: 

128 If any parameter is invalid or does not match the expected format. 

129 """ 

130 valid_img_types = {"jpeg", "png", "webp"} 

131 valid_themes = {"default", "neutral", "dark", "forest"} 

132 valid_formats = {"img", "svg", "pdf"} 

133 

134 params.setdefault("format", "img") 

135 params.setdefault("type", "png") 

136 params.setdefault("theme", "neutral") 

137 

138 if params["format"] not in valid_formats: 

139 raise ValueError(f"Invalid image format: {params['format']}. Valid options are: {valid_formats}.") 

140 

141 if params["format"] == "img" and params["type"] not in valid_img_types: 

142 raise ValueError(f"Invalid image type: {params['type']}. Valid options are: {valid_img_types}.") 

143 

144 if params["theme"] not in valid_themes: 

145 raise ValueError(f"Invalid theme: {params['theme']}. Valid options are: {valid_themes}.") 

146 

147 if "width" in params and not isinstance(params["width"], int): 

148 raise ValueError("Width must be an integer.") 

149 if "height" in params and not isinstance(params["height"], int): 

150 raise ValueError("Height must be an integer.") 

151 

152 if "scale" in params and not 1 <= params["scale"] <= 3: 

153 raise ValueError("Scale must be a number between 1 and 3.") 

154 if "scale" in params and not ("width" in params or "height" in params): 

155 raise ValueError("Scale is only allowed when width or height is set.") 

156 

157 if "bgColor" in params and not isinstance(params["bgColor"], str): 

158 raise ValueError("Background color must be a string.") 

159 

160 # PDF specific parameters 

161 if params["format"] == "pdf": 

162 if "fit" in params and not isinstance(params["fit"], bool): 

163 raise ValueError("Fit must be a boolean.") 

164 if "paper" in params and not isinstance(params["paper"], str): 

165 raise ValueError("Paper size must be a string (e.g., 'a4', 'a3').") 

166 if "landscape" in params and not isinstance(params["landscape"], bool): 

167 raise ValueError("Landscape must be a boolean.") 

168 if "fit" in params and ("paper" in params or "landscape" in params): 

169 logger.warning("`fit` overrides `paper` and `landscape` for PDFs. Ignoring `paper` and `landscape`.") 

170 

171 

172# Magic-byte signatures used to verify a Mermaid server response matches the requested output format. 

173_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" 

174_JPEG_SIGNATURE = b"\xff\xd8\xff" 

175_PDF_SIGNATURE = b"%PDF-" 

176_RIFF_SIGNATURE = b"RIFF" 

177_WEBP_SIGNATURE = b"WEBP" 

178_SVG_PREFIXES = (b"<?xml", b"<svg") 

179 

180 

181def _validate_image_response(resp: httpx.Response, params: dict[str, Any]) -> None: 

182 """ 

183 Validate that the Mermaid server response actually contains the expected image/SVG/PDF data. 

184 

185 `Pipeline.draw()` writes the raw response body to disk, so a misconfigured or malicious 

186 `server_url` could otherwise cause arbitrary content (e.g. an HTML error page or a crafted 

187 payload) to be written verbatim to the output path. As defense-in-depth we check both the 

188 `Content-Type` header (which the server controls and could spoof) and the response body's 

189 magic-byte signature (which is harder to forge while still producing a usable payload). 

190 

191 :param resp: 

192 The HTTP response returned by the Mermaid server. 

193 :param params: 

194 Validated Mermaid parameters; used to determine the expected output format. 

195 :raises PipelineDrawingError: 

196 If the response is empty or does not match the expected format. 

197 """ 

198 content = resp.content 

199 if not content: 

200 raise PipelineDrawingError("The Mermaid server returned an empty response; no image will be saved.") 

201 

202 output_format = params.get("format", "img") 

203 img_type = params.get("type", "png") 

204 

205 # (human-readable label, expected Content-Type prefix, body signature check) 

206 content_type_prefixes: tuple[str, ...] 

207 if output_format == "svg": 

208 expected_label = "SVG" 

209 content_type_prefixes = ("image/svg+xml", "text/xml", "application/xml") 

210 stripped = content.lstrip()[:512].lower() 

211 body_ok = stripped.startswith(_SVG_PREFIXES) or _SVG_PREFIXES[1] in stripped 

212 elif output_format == "pdf": 

213 expected_label = "PDF" 

214 content_type_prefixes = ("application/pdf",) 

215 body_ok = content.startswith(_PDF_SIGNATURE) 

216 elif img_type == "jpeg": 

217 expected_label = "JPEG image" 

218 content_type_prefixes = ("image/jpeg",) 

219 body_ok = content.startswith(_JPEG_SIGNATURE) 

220 elif img_type == "webp": 

221 expected_label = "WebP image" 

222 content_type_prefixes = ("image/webp",) 

223 body_ok = content[0:4] == _RIFF_SIGNATURE and content[8:12] == _WEBP_SIGNATURE 

224 else: # png (default) 

225 expected_label = "PNG image" 

226 content_type_prefixes = ("image/png",) 

227 body_ok = content.startswith(_PNG_SIGNATURE) 

228 

229 # The Content-Type header is server-controlled, so a mismatch is only a warning: the 

230 # authoritative check is the body signature below. 

231 content_type = resp.headers.get("content-type", "").split(";")[0].strip().lower() 

232 if content_type and not content_type.startswith(content_type_prefixes): 

233 logger.warning( 

234 "The Mermaid server returned an unexpected Content-Type '{content_type}' (expected {expected}).", 

235 content_type=content_type, 

236 expected=expected_label, 

237 ) 

238 

239 if not body_ok: 

240 raise PipelineDrawingError( 

241 f"The Mermaid server response does not look like a valid {expected_label}. " 

242 f"This can happen if 'server_url' points to a server that is not a Mermaid renderer. " 

243 f"To avoid writing untrusted content to disk, no file will be saved." 

244 ) 

245 

246 

247def _to_mermaid_image( 

248 graph: networkx.MultiDiGraph, 

249 server_url: str = "https://mermaid.ink", 

250 params: dict | None = None, 

251 timeout: int = 30, 

252 super_component_mapping: dict[str, str] | None = None, 

253) -> bytes: 

254 """ 

255 Renders a pipeline using a Mermaid server. 

256 

257 :param graph: 

258 The graph to render as a Mermaid pipeline. 

259 :param server_url: 

260 Base URL of the Mermaid server (default: 'https://mermaid.ink'). 

261 :param params: 

262 Dictionary of customization parameters. See `validate_mermaid_params` for valid keys. 

263 :param timeout: 

264 Timeout in seconds for the request to the Mermaid server. 

265 :param super_component_mapping: 

266 Mapping of component names to the name of the SuperComponent that contains them, used to 

267 group those components visually in the rendered diagram. 

268 :returns: 

269 The image, SVG, or PDF data returned by the Mermaid server as bytes. 

270 :raises ValueError: 

271 If any parameter is invalid or does not match the expected format. 

272 :raises PipelineDrawingError: 

273 If there is an issue connecting to the Mermaid server or the server returns an error. 

274 """ 

275 

276 if params is None: 

277 params = {} 

278 

279 _validate_mermaid_params(params) 

280 

281 theme = params.get("theme") 

282 init_params = json.dumps({"theme": theme}) 

283 

284 # Copy the graph to avoid modifying the original 

285 graph_styled = _to_mermaid_text(graph.copy(), init_params, super_component_mapping) 

286 json_string = json.dumps({"code": graph_styled}) 

287 

288 # Compress the JSON string with zlib (RFC 1950) 

289 compressor = zlib.compressobj(level=9, wbits=15) 

290 compressed_data = compressor.compress(json_string.encode("utf-8")) + compressor.flush() 

291 compressed_url_safe_base64 = base64.urlsafe_b64encode(compressed_data).decode("utf-8").strip() 

292 

293 # Determine the correct endpoint 

294 endpoint_format = params.get("format", "img") # Default to /img endpoint 

295 if endpoint_format not in {"img", "svg", "pdf"}: 

296 raise ValueError(f"Invalid format: {endpoint_format}. Valid options are 'img', 'svg', or 'pdf'.") 

297 

298 # Construct the URL without query parameters 

299 url = f"{server_url}/{endpoint_format}/pako:{compressed_url_safe_base64}" 

300 

301 # Add query parameters adhering to mermaid.ink documentation 

302 query_params = [] 

303 for key, value in params.items(): 

304 if key not in {"theme", "format"}: # Exclude theme (handled in init_params) and format (endpoint-specific) 

305 if value is True: 

306 query_params.append(f"{key}") 

307 else: 

308 query_params.append(f"{key}={value}") 

309 

310 if query_params: 

311 url += "?" + "&".join(query_params) 

312 

313 logger.debug("Rendering graph at {url}", url=url) 

314 try: 

315 resp = httpx.get(url, timeout=timeout) 

316 if resp.status_code >= 400: 

317 logger.warning( 

318 "Failed to draw the pipeline: {server_url} returned status {status_code}", 

319 server_url=server_url, 

320 status_code=resp.status_code, 

321 ) 

322 logger.info("Exact URL requested: {url}", url=url) 

323 logger.warning("No pipeline diagram will be saved.") 

324 resp.raise_for_status() 

325 

326 except Exception as exc: 

327 logger.warning( 

328 "Failed to draw the pipeline: could not connect to {server_url} ({error})", server_url=server_url, error=exc 

329 ) 

330 logger.info("Exact URL requested: {url}", url=url) 

331 logger.warning("No pipeline diagram will be saved.") 

332 raise PipelineDrawingError(f"There was an issue with {server_url}, see the stacktrace for details.") from exc 

333 

334 # Validate the response before it gets written to disk by the caller, so that a misconfigured 

335 # or malicious server cannot cause arbitrary content to be saved to the output path. 

336 _validate_image_response(resp, params) 

337 

338 return resp.content 

339 

340 

341def _to_mermaid_text( 

342 graph: networkx.MultiDiGraph, init_params: str | dict, super_component_mapping: dict[str, str] | None = None 

343) -> str: 

344 """ 

345 Converts a Networkx graph into Mermaid syntax. 

346 

347 The output of this function can be used in the documentation with `mermaid` codeblocks and will be 

348 automatically rendered. 

349 

350 :param graph: The graph to convert to Mermaid syntax 

351 :param init_params: Initialization parameters for Mermaid 

352 :param super_component_mapping: Mapping of component names to super component names 

353 """ 

354 # Copy the graph to avoid modifying the original 

355 graph = _prepare_for_drawing(graph.copy()) 

356 sockets = { 

357 comp: "".join( 

358 [ 

359 f"<li>{name} ({_type_name(socket.type)})</li>" 

360 for name, socket in data.get("input_sockets", {}).items() 

361 if (not socket.is_mandatory and not socket.senders) or socket.is_variadic 

362 ] 

363 ) 

364 for comp, data in graph.nodes(data=True) 

365 } 

366 optional_inputs = { 

367 comp: f"<br><br>Optional inputs:<ul style='text-align:left;'>{sockets}</ul>" if sockets else "" 

368 for comp, sockets in sockets.items() 

369 } 

370 

371 # Create node definitions 

372 states = {} 

373 super_component_components = super_component_mapping.keys() if super_component_mapping else {} 

374 

375 # color variations for super components 

376 super_component_colors = {} 

377 if super_component_components: 

378 unique_super_components = set(super_component_mapping.values()) # type:ignore 

379 color_variations = generate_color_variations(n=len(unique_super_components)) 

380 super_component_colors = dict(zip(unique_super_components, color_variations, strict=True)) 

381 

382 # Generate style definitions for each super component 

383 style_definitions = [] 

384 for super_comp, color in super_component_colors.items(): 

385 style_definitions.append(f"classDef {super_comp} fill:{color},color:white;") 

386 

387 for comp, data in graph.nodes(data=True): 

388 if comp in ["input", "output"]: 

389 continue 

390 

391 # styling based on whether the component is a SuperComponent 

392 if comp in super_component_components: 

393 super_component_name = super_component_mapping[comp] # type:ignore 

394 style = super_component_name 

395 else: 

396 style = "component" 

397 node_def = f'{comp}["<b>{comp}</b><br><small><i>{type(data["instance"]).__name__}{optional_inputs[comp]}</i></small>"]:::{style}' # noqa: E501 

398 states[comp] = node_def 

399 

400 connections_list = [] 

401 for from_comp, to_comp, conn_data in graph.edges(data=True): 

402 if from_comp != "input" and to_comp != "output": 

403 arrowtail = ARROWTAIL_MANDATORY if conn_data["mandatory"] else ARROWTAIL_OPTIONAL 

404 arrowhead = ARROWHEAD_MANDATORY if conn_data["mandatory"] else ARROWHEAD_OPTIONAL 

405 label = f'"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"' 

406 conn_string = f"{states[from_comp]} {arrowtail} {label} {arrowhead} {states[to_comp]}" 

407 connections_list.append(conn_string) 

408 

409 input_connections = [ 

410 f'i{{&ast;}}--"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"--> {states[to_comp]}' 

411 for _, to_comp, conn_data in graph.out_edges("input", data=True) 

412 ] 

413 output_connections = [ 

414 f'{states[from_comp]}--"{conn_data["label"]}<br><small><i>{conn_data["conn_type"]}</i></small>"--> o{{&ast;}}' 

415 for from_comp, _, conn_data in graph.in_edges("output", data=True) 

416 ] 

417 connections = "\n".join(connections_list + input_connections + output_connections) 

418 

419 # Create legend 

420 legend_nodes = [] 

421 if super_component_colors: 

422 legend_nodes.append("subgraph Legend") 

423 for super_comp in super_component_colors: 

424 legend_id = f"legend_{super_comp}" 

425 legend_nodes.append(f'{legend_id}["{super_comp}"]:::{super_comp}') 

426 legend_nodes.append("end") 

427 connections += "\n" + "\n".join(legend_nodes) 

428 

429 # Add style definitions to the template 

430 graph_styled = MERMAID_STYLED_TEMPLATE.format( 

431 params=init_params, connections=connections, style_definitions="\n".join(style_definitions) 

432 ) 

433 logger.debug("Mermaid diagram:\n{diagram}", diagram=graph_styled) 

434 

435 return graph_styled