Coverage for haystack/core/pipeline/base.py: 92%

636 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 itertools 

6import json 

7from collections import defaultdict 

8from collections.abc import Iterator, Mapping, Sequence 

9from contextlib import AbstractContextManager as ContextManager 

10from datetime import datetime 

11from enum import IntEnum 

12from pathlib import Path 

13from typing import Any, TextIO, TypeVar, Union, get_args 

14 

15import networkx 

16from typing_extensions import Self 

17 

18from haystack import logging, tracing 

19from haystack.core.component import Component, InputSocket, OutputSocket, component 

20from haystack.core.errors import ( 

21 DeserializationError, 

22 PipelineComponentsBlockedError, 

23 PipelineConnectError, 

24 PipelineDrawingError, 

25 PipelineError, 

26 PipelineMaxComponentRuns, 

27 PipelineRuntimeError, 

28 PipelineValidationError, 

29) 

30from haystack.core.pipeline.component_checks import ( 

31 _NoOutputProduced, 

32 all_predecessors_executed, 

33 are_all_sockets_ready, 

34 can_component_run, 

35 is_any_greedy_socket_ready, 

36) 

37from haystack.core.pipeline.utils import FIFOPriorityQueue, _deepcopy_with_exceptions, parse_connect_string 

38from haystack.core.serialization import ( 

39 DeserializationCallbacks, 

40 component_from_dict, 

41 component_to_dict, 

42 generate_qualified_class_name, 

43) 

44from haystack.core.serialization_security import ( 

45 _check_module_allowed, 

46 _deserialization_context, 

47 mark_deserialization_internal, 

48) 

49from haystack.core.type_utils import ( 

50 ConversionStrategyType, 

51 _convert_value, 

52 _safe_get_origin, 

53 _type_name, 

54 _types_are_compatible, 

55) 

56from haystack.marshal import Marshaller, YamlMarshaller 

57from haystack.utils import is_in_jupyter, type_serialization 

58 

59from .descriptions import find_pipeline_inputs, find_pipeline_outputs 

60from .draw import _to_mermaid_image 

61 

62DEFAULT_MARSHALLER = YamlMarshaller() 

63 

64# We use a generic type to annotate the return value of class methods, 

65# so that static analyzers won't be confused when derived classes 

66# use those methods. 

67T = TypeVar("T", bound="PipelineBase") 

68 

69logger = logging.getLogger(__name__) 

70 

71 

72# Constants for tracing tags 

73_COMPONENT_INPUT = "haystack.component.input" 

74_COMPONENT_OUTPUT = "haystack.component.output" 

75_COMPONENT_VISITS = "haystack.component.visits" 

76 

77InputsType = dict[str, dict[str, list[dict[str, Any]]]] 

78 

79 

80class ComponentPriority(IntEnum): 

81 HIGHEST = 1 

82 READY = 2 

83 DEFER = 3 

84 BLOCKED = 4 

85 

86 

87class PipelineBase: # noqa: PLW1641 

88 """ 

89 Components orchestration engine. 

90 

91 Builds a graph of components and orchestrates their execution according to the execution graph. 

92 """ 

93 

94 def __init__( 

95 self, 

96 metadata: dict[str, Any] | None = None, 

97 max_runs_per_component: int = 100, 

98 connection_type_validation: bool = True, 

99 ) -> None: 

100 """ 

101 Creates the Pipeline. 

102 

103 :param metadata: 

104 Arbitrary dictionary to store metadata about this `Pipeline`. Make sure all the values contained in 

105 this dictionary can be serialized and deserialized if you wish to save this `Pipeline` to file. 

106 :param max_runs_per_component: 

107 How many times the `Pipeline` can run the same Component. 

108 If this limit is reached a `PipelineMaxComponentRuns` exception is raised. 

109 If not set defaults to 100 runs per Component. 

110 :param connection_type_validation: Whether the pipeline will validate the types of the connections. 

111 Defaults to True. 

112 """ 

113 self._telemetry_runs = 0 

114 self._last_telemetry_sent: datetime | None = None 

115 self.metadata = metadata or {} 

116 self.graph = networkx.MultiDiGraph() 

117 self._max_runs_per_component = max_runs_per_component 

118 self._connection_type_validation = connection_type_validation 

119 

120 def __eq__(self, other: object) -> bool: 

121 """ 

122 Pipeline equality is defined by their type and the equality of their serialized form. 

123 

124 Pipelines of the same type share every metadata, node and edge, but they're not required to use 

125 the same node instances: this allows pipeline saved and then loaded back to be equal to themselves. 

126 """ 

127 if not isinstance(other, type(self)): 

128 return False 

129 return self.to_dict() == other.to_dict() 

130 

131 def __repr__(self) -> str: 

132 """ 

133 Returns a text representation of the Pipeline. 

134 """ 

135 res = f"{object.__repr__(self)}\n" 

136 if self.metadata: 

137 res += "🧱 Metadata\n" 

138 for k, v in self.metadata.items(): 

139 res += f" - {k}: {v}\n" 

140 

141 res += "🚅 Components\n" 

142 for name, instance in self.graph.nodes(data="instance"): 

143 res += f" - {name}: {instance.__class__.__name__}\n" 

144 

145 res += "🛤️ Connections\n" 

146 for sender, receiver, edge_data in self.graph.edges(data=True): 

147 sender_socket = edge_data["from_socket"].name 

148 receiver_socket = edge_data["to_socket"].name 

149 res += f" - {sender}.{sender_socket} -> {receiver}.{receiver_socket} ({edge_data['conn_type']})\n" 

150 

151 return res 

152 

153 def to_dict(self) -> dict[str, Any]: 

154 """ 

155 Serializes the pipeline to a dictionary. 

156 

157 This is meant to be an intermediate representation but it can be also used to save a pipeline to file. 

158 

159 :returns: 

160 Dictionary with serialized data. 

161 """ 

162 components = {} 

163 for name, instance in self.graph.nodes(data="instance"): 

164 components[name] = component_to_dict(instance, name) 

165 

166 connections = [] 

167 for sender, receiver, edge_data in self.graph.edges.data(): 

168 sender_socket = edge_data["from_socket"].name 

169 receiver_socket = edge_data["to_socket"].name 

170 connections.append({"sender": f"{sender}.{sender_socket}", "receiver": f"{receiver}.{receiver_socket}"}) 

171 return { 

172 "metadata": self.metadata, 

173 "max_runs_per_component": self._max_runs_per_component, 

174 "components": components, 

175 "connections": connections, 

176 "connection_type_validation": self._connection_type_validation, 

177 } 

178 

179 @classmethod 

180 @mark_deserialization_internal 

181 def from_dict( 

182 cls: type[T], 

183 data: dict[str, Any], 

184 callbacks: DeserializationCallbacks | None = None, 

185 *, 

186 allowed_modules: list[str] | None = None, 

187 unsafe: bool = False, 

188 **kwargs: Any, 

189 ) -> T: 

190 """ 

191 Deserializes the pipeline from a dictionary. 

192 

193 :param data: 

194 Dictionary to deserialize from. 

195 :param callbacks: 

196 Callbacks to invoke during deserialization. 

197 :param allowed_modules: 

198 Additional module patterns whose classes may be imported during deserialization. 

199 By default, only modules under `haystack`, `haystack_integrations`, `haystack_experimental`, 

200 `builtins`, `typing`, and `collections` are trusted. See 

201 `haystack.core.serialization.allow_deserialization_module` for the matching semantics. 

202 :param unsafe: 

203 If `True`, bypass the deserialization allowlist entirely. Only use this when you fully 

204 trust the source of the serialized data — any class in any importable module can be 

205 instantiated. 

206 :param kwargs: 

207 `components`: a dictionary of `{name: instance}` to reuse instances of components instead of creating new 

208 ones. 

209 :returns: 

210 Deserialized component. 

211 """ 

212 with _deserialization_context(allowed_modules=allowed_modules, unsafe=unsafe): 

213 return cls._from_dict_impl(data, callbacks, **kwargs) 

214 

215 @classmethod 

216 def _from_dict_impl( 

217 cls: type[T], data: dict[str, Any], callbacks: DeserializationCallbacks | None = None, **kwargs: Any 

218 ) -> T: 

219 data_copy = _deepcopy_with_exceptions(data) # to prevent modification of original data 

220 metadata = data_copy.get("metadata", {}) 

221 max_runs_per_component = data_copy.get("max_runs_per_component", 100) 

222 connection_type_validation = data_copy.get("connection_type_validation", True) 

223 pipe = cls( 

224 metadata=metadata, 

225 max_runs_per_component=max_runs_per_component, 

226 connection_type_validation=connection_type_validation, 

227 ) 

228 components_to_reuse = kwargs.get("components", {}) 

229 for name, component_data in data_copy.get("components", {}).items(): 

230 if name in components_to_reuse: 

231 # Reuse an instance 

232 instance = components_to_reuse[name] 

233 else: 

234 if "type" not in component_data: 

235 raise PipelineError(f"Missing 'type' in component '{name}'") 

236 

237 component_type = component_data["type"] 

238 if isinstance(component_type, str) and "." in component_type: 

239 _check_module_allowed(component_type.rsplit(".", 1)[0]) 

240 

241 if component_type not in component.registry: 

242 try: 

243 # Import the module first... 

244 module, _ = component_type.rsplit(".", 1) 

245 logger.debug("Trying to import module {module_name}", module_name=module) 

246 type_serialization.thread_safe_import(module) 

247 # ...then try again 

248 if component_type not in component.registry: 

249 raise PipelineError( # noqa: TRY301 

250 f"Successfully imported module '{module}' but couldn't find " 

251 f"'{component_type}' in the component registry.\n" 

252 f"The component might be registered under a different path. " 

253 f"Here are the registered components:\n {list(component.registry.keys())}\n" 

254 ) 

255 except (ImportError, PipelineError, ValueError) as e: 

256 raise PipelineError( 

257 f"Component '{component_type}' (name: '{name}') not imported. Please " 

258 f"check that the package is installed and the component path is correct." 

259 ) from e 

260 

261 # Create a new one 

262 component_class = component.registry[component_type] 

263 

264 try: 

265 instance = component_from_dict(component_class, component_data, name, callbacks) 

266 except Exception as e: 

267 # Convert to JSON with indentation, truncate if too long 

268 try: 

269 data_str = json.dumps(component_data, default=str, indent=2) 

270 except Exception: 

271 data_str = str(component_data) 

272 

273 max_len = 1000 

274 if len(data_str) > max_len: 

275 data_str = data_str[:max_len] + "\n... (truncated)" 

276 

277 msg = ( 

278 f"Couldn't deserialize component '{name}' of class '{component_class.__name__}' " 

279 f"with the following data:\n{data_str}\n\n" 

280 f"Original error: {e}" 

281 ) 

282 raise DeserializationError(msg) from e 

283 pipe.add_component(name=name, instance=instance) 

284 

285 for connection in data.get("connections", []): 

286 if "sender" not in connection: 

287 raise PipelineError(f"Missing sender in connection: {connection}") 

288 if "receiver" not in connection: 

289 raise PipelineError(f"Missing receiver in connection: {connection}") 

290 pipe.connect(sender=connection["sender"], receiver=connection["receiver"]) 

291 

292 return pipe 

293 

294 def dumps(self, marshaller: Marshaller = DEFAULT_MARSHALLER) -> str: 

295 """ 

296 Returns the string representation of this pipeline according to the format dictated by the `Marshaller` in use. 

297 

298 :param marshaller: 

299 The Marshaller used to create the string representation. Defaults to `YamlMarshaller`. 

300 :returns: 

301 A string representing the pipeline. 

302 """ 

303 return marshaller.marshal(self.to_dict()) 

304 

305 def dump(self, fp: TextIO, marshaller: Marshaller = DEFAULT_MARSHALLER) -> None: 

306 """ 

307 Writes the string representation of this pipeline to the file-like object passed in the `fp` argument. 

308 

309 :param fp: 

310 A file-like object ready to be written to. 

311 :param marshaller: 

312 The Marshaller used to create the string representation. Defaults to `YamlMarshaller`. 

313 """ 

314 fp.write(marshaller.marshal(self.to_dict())) 

315 

316 @classmethod 

317 @mark_deserialization_internal 

318 def loads( 

319 cls: type[T], 

320 data: str | bytes | bytearray, 

321 marshaller: Marshaller = DEFAULT_MARSHALLER, 

322 callbacks: DeserializationCallbacks | None = None, 

323 *, 

324 allowed_modules: list[str] | None = None, 

325 unsafe: bool = False, 

326 ) -> T: 

327 """ 

328 Creates a `Pipeline` object from the string representation passed in the `data` argument. 

329 

330 :param data: 

331 The string representation of the pipeline, can be `str`, `bytes` or `bytearray`. 

332 :param marshaller: 

333 The Marshaller used to create the string representation. Defaults to `YamlMarshaller`. 

334 :param callbacks: 

335 Callbacks to invoke during deserialization. 

336 :param allowed_modules: 

337 Additional module patterns whose classes may be imported during deserialization. 

338 By default, only modules under `haystack`, `haystack_integrations`, `haystack_experimental`, 

339 `builtins`, `typing`, and `collections` are trusted. 

340 :param unsafe: 

341 If `True`, bypass the deserialization allowlist entirely. Only use this when you fully 

342 trust the source of the serialized data — any class in any importable module can be 

343 instantiated. 

344 :raises DeserializationError: 

345 If an error occurs during deserialization. 

346 :returns: 

347 A `Pipeline` object. 

348 """ 

349 try: 

350 deserialized_data = marshaller.unmarshal(data) 

351 except Exception as e: 

352 raise DeserializationError( 

353 "Error while unmarshalling serialized pipeline data. This is usually " 

354 "caused by malformed or invalid syntax in the serialized representation." 

355 ) from e 

356 

357 return cls.from_dict(deserialized_data, callbacks, allowed_modules=allowed_modules, unsafe=unsafe) 

358 

359 @classmethod 

360 @mark_deserialization_internal 

361 def load( 

362 cls: type[T], 

363 fp: TextIO, 

364 marshaller: Marshaller = DEFAULT_MARSHALLER, 

365 callbacks: DeserializationCallbacks | None = None, 

366 *, 

367 allowed_modules: list[str] | None = None, 

368 unsafe: bool = False, 

369 ) -> T: 

370 """ 

371 Creates a `Pipeline` object from a string representation. 

372 

373 The string representation is read from the file-like object passed in the `fp` argument. 

374 

375 

376 :param fp: 

377 A file-like object ready to be read from. 

378 :param marshaller: 

379 The Marshaller used to create the string representation. Defaults to `YamlMarshaller`. 

380 :param callbacks: 

381 Callbacks to invoke during deserialization. 

382 :param allowed_modules: 

383 Additional module patterns whose classes may be imported during deserialization. 

384 By default, only modules under `haystack`, `haystack_integrations`, `haystack_experimental`, 

385 `builtins`, `typing`, and `collections` are trusted. 

386 :param unsafe: 

387 If `True`, bypass the deserialization allowlist entirely. Only use this when you fully 

388 trust the source of the serialized data — any class in any importable module can be 

389 instantiated. 

390 :raises DeserializationError: 

391 If an error occurs during deserialization. 

392 :returns: 

393 A `Pipeline` object. 

394 """ 

395 return cls.loads(fp.read(), marshaller, callbacks, allowed_modules=allowed_modules, unsafe=unsafe) 

396 

397 # Self preserves the concrete subclass for fluent calls, so Pipeline().add_component(...).run() type-checks. 

398 def add_component(self, name: str, instance: Component) -> Self: 

399 """ 

400 Add the given component to the pipeline. 

401 

402 Components are not connected to anything by default: use `Pipeline.connect()` to connect components together. 

403 Component names must be unique, and a component instance can only be added to one pipeline at a time. 

404 Adding the same component instance with the same name more than once is a no-op. 

405 

406 :param name: 

407 The name of the component to add. 

408 :param instance: 

409 The component instance to add. 

410 :returns: 

411 The Pipeline instance. 

412 

413 :raises ValueError: 

414 If a different component with the same name already exists. 

415 :raises PipelineValidationError: 

416 If the given instance is not a component. 

417 :raises PipelineError: 

418 If the component instance is already in this pipeline under another name or is in another pipeline. 

419 """ 

420 if not self._validate_component(name, instance): 

421 return self 

422 

423 self._add_component_to_graph(name, instance) 

424 return self 

425 

426 def add_components(self, components: dict[str, Component]) -> Self: 

427 """ 

428 Add multiple components to the pipeline. 

429 

430 Components are not connected to anything by default: use `Pipeline.connect()` to connect components together. 

431 Before adding anything, Haystack checks that every name is valid, every value is a Component instance, 

432 no name belongs to a different component, and no instance is assigned to another name or pipeline. 

433 If any check fails, the pipeline remains unchanged. 

434 Components already present under the same name are ignored when they are the exact same instances. 

435 

436 :param components: 

437 A dictionary that maps component names to component instances. 

438 :returns: 

439 The Pipeline instance. 

440 

441 :raises ValueError: 

442 If a component name is invalid or already belongs to a different component in this pipeline. 

443 :raises PipelineValidationError: 

444 If one of the given instances is not a component. 

445 :raises PipelineError: 

446 If a component instance is already in this pipeline under another name, is in another pipeline, 

447 or occurs more than once in the mapping. 

448 """ 

449 components_to_add: list[tuple[str, Component]] = [] 

450 component_names_by_id: dict[int, str] = {} 

451 

452 for name, instance in components.items(): 

453 if not self._validate_component(name, instance): 

454 continue 

455 

456 instance_id = id(instance) 

457 previous_name = component_names_by_id.get(instance_id) 

458 if previous_name is not None: 

459 raise PipelineError( 

460 f"Component instance cannot be added to the pipeline more than once. " 

461 f"It is mapped to both '{previous_name}' and '{name}'." 

462 ) 

463 

464 component_names_by_id[instance_id] = name 

465 components_to_add.append((name, instance)) 

466 

467 for name, instance in components_to_add: 

468 self._add_component_to_graph(name, instance) 

469 

470 return self 

471 

472 def _validate_component(self, name: str, instance: Component) -> bool: 

473 """Validate a component before adding it, returning whether it needs to be added.""" 

474 # Component names are unique 

475 if name in self.graph.nodes: 

476 if self.graph.nodes[name]["instance"] is instance: 

477 return False 

478 raise ValueError(f"A component named '{name}' already exists in this pipeline: choose another name.") 

479 

480 # Components can't be named `_debug` 

481 if name == "_debug": 

482 raise ValueError("'_debug' is a reserved name for debug output. Choose another name.") 

483 

484 # Component names can't have "." 

485 if "." in name: 

486 raise ValueError(f"{name} is an invalid component name, cannot contain '.' (dot) characters.") 

487 

488 # Component instances must be components 

489 if not isinstance(instance, Component): 

490 raise PipelineValidationError( 

491 f"'{type(instance)}' doesn't seem to be a component. Is this class decorated with @component?" 

492 ) 

493 

494 if owning_pipeline := getattr(instance, "__haystack_added_to_pipeline__", None): 

495 if owning_pipeline is self: 

496 existing_name = self.get_component_name(instance) 

497 msg = ( 

498 f"Component has already been added to this Pipeline under the name '{existing_name}'. " 

499 "A component instance can only be added once." 

500 ) 

501 else: 

502 msg = ( 

503 "Component has already been added in another Pipeline. " 

504 "Components can't be shared between Pipelines. Create a new instance instead." 

505 ) 

506 raise PipelineError(msg) 

507 

508 return True 

509 

510 def _add_component_to_graph(self, name: str, instance: Component) -> None: 

511 """Add an already validated component to the graph.""" 

512 setattr(instance, "__haystack_added_to_pipeline__", self) # noqa: B010 

513 setattr(instance, "__component_name__", name) # noqa: B010 

514 

515 # Add component to the graph, disconnected 

516 logger.debug("Adding component '{component_name}' ({component})", component_name=name, component=instance) 

517 # We're completely sure the fields exist so we ignore the type error 

518 self.graph.add_node( 

519 name, 

520 instance=instance, 

521 input_sockets=instance.__haystack_input__._sockets_dict, # type: ignore[attr-defined] 

522 output_sockets=instance.__haystack_output__._sockets_dict, # type: ignore[attr-defined] 

523 visits=0, 

524 ) 

525 

526 def remove_component(self, name: str) -> Component: 

527 """ 

528 Remove and returns component from the pipeline. 

529 

530 Remove an existing component from the pipeline by providing its name. 

531 All edges that connect to the component will also be deleted. 

532 

533 :param name: 

534 The name of the component to remove. 

535 :returns: 

536 The removed Component instance. 

537 

538 :raises ValueError: 

539 If there is no component with that name already in the Pipeline. 

540 """ 

541 

542 # Check that a component with that name is in the Pipeline 

543 try: 

544 instance = self.get_component(name) 

545 except ValueError as exc: 

546 raise ValueError( 

547 f"There is no component named '{name}' in the pipeline. The valid component names are: ", 

548 ", ".join(n for n in self.graph.nodes), 

549 ) from exc 

550 

551 # Remove this component's name from its neighbors' sockets before the edges are gone, 

552 # otherwise the surviving components are left holding dangling references to it. 

553 for _, _, edge_data in self.graph.in_edges(name, data=True): 

554 sender_socket = edge_data["from_socket"] 

555 sender_socket.receivers = [r for r in sender_socket.receivers if r != name] 

556 for _, _, edge_data in self.graph.out_edges(name, data=True): 

557 receiver_socket = edge_data["to_socket"] 

558 receiver_socket.senders = [s for s in receiver_socket.senders if s != name] 

559 if ( 

560 len(receiver_socket.senders) <= 1 

561 and receiver_socket.is_lazy_variadic 

562 and not receiver_socket.wrap_input_in_list 

563 ): 

564 receiver_socket.is_lazy_variadic = False 

565 receiver_socket.wrap_input_in_list = True 

566 

567 # Delete component from the graph, deleting all its connections 

568 self.graph.remove_node(name) 

569 

570 # Reset the Component sockets' senders and receivers 

571 input_sockets = instance.__haystack_input__._sockets_dict # type: ignore[attr-defined] 

572 for socket in input_sockets.values(): 

573 socket.senders = [] 

574 if socket.is_lazy_variadic and not socket.wrap_input_in_list: 

575 socket.is_lazy_variadic = False 

576 socket.wrap_input_in_list = True 

577 

578 output_sockets = instance.__haystack_output__._sockets_dict # type: ignore[attr-defined] 

579 for socket in output_sockets.values(): 

580 socket.receivers = [] 

581 

582 # Reset the Component's pipeline reference 

583 setattr(instance, "__haystack_added_to_pipeline__", None) # noqa: B010 

584 

585 return instance 

586 

587 def connect(self, sender: str, receiver: str) -> Self: # noqa: PLR0915 PLR0912 C901 

588 """ 

589 Connects two components together. 

590 

591 All components to connect must exist in the pipeline. 

592 If connecting to a component that has several output connections, specify the inputs and output names as 

593 'component_name.connections_name'. 

594 

595 If multiple senders are connected to the same list-typed receiver socket, the socket is 

596 promoted to a lazy variadic socket so it can accept all incoming values. With the synchronous 

597 `run`, the resulting list is ordered alphabetically by sender component name, not by the order in 

598 which `connect()` was called. With the asynchronous run path (`run_async`), no ordering is 

599 guaranteed, since components in different branches may run in parallel. 

600 

601 :param sender: 

602 The component that delivers the value. This can be either just a component name or can be 

603 in the format `component_name.connection_name` if the component has multiple outputs. 

604 :param receiver: 

605 The component that receives the value. This can be either just a component name or can be 

606 in the format `component_name.connection_name` if the component has multiple inputs. 

607 

608 :returns: 

609 The Pipeline instance. 

610 

611 :raises PipelineConnectError: 

612 If the two components cannot be connected (for example if one of the components is 

613 not present in the pipeline, or the connections don't match by type, and so on). 

614 """ 

615 # Edges may be named explicitly by passing 'node_name.edge_name' to connect(). 

616 sender_component_name, sender_socket_name = parse_connect_string(sender) 

617 receiver_component_name, receiver_socket_name = parse_connect_string(receiver) 

618 

619 if sender_component_name == receiver_component_name: 

620 raise PipelineConnectError("Connecting a Component to itself is not supported.") 

621 

622 # Get the nodes data. 

623 try: 

624 sender_sockets = self.graph.nodes[sender_component_name]["output_sockets"] 

625 except KeyError as exc: 

626 raise ValueError(f"Component named {sender_component_name} not found in the pipeline.") from exc 

627 try: 

628 receiver_sockets = self.graph.nodes[receiver_component_name]["input_sockets"] 

629 except KeyError as exc: 

630 raise ValueError(f"Component named {receiver_component_name} not found in the pipeline.") from exc 

631 

632 if not sender_sockets: 

633 raise PipelineConnectError( 

634 f"'{sender_component_name}' does not have any output connections. " 

635 f"Please check that the output types of '{sender_component_name}.run' are set, " 

636 f"for example by using the '@component.output_types' decorator." 

637 ) 

638 

639 # If the name of either socket is given, get the socket 

640 sender_socket: OutputSocket | None = None 

641 if sender_socket_name: 

642 sender_socket = sender_sockets.get(sender_socket_name) 

643 if not sender_socket: 

644 raise PipelineConnectError( 

645 f"'{sender}' does not exist. " 

646 f"Output connections of {sender_component_name} are: " 

647 + ", ".join([f"{name} (type {_type_name(socket.type)})" for name, socket in sender_sockets.items()]) 

648 ) 

649 

650 receiver_socket: InputSocket | None = None 

651 if receiver_socket_name: 

652 receiver_socket = receiver_sockets.get(receiver_socket_name) 

653 if not receiver_socket: 

654 raise PipelineConnectError( 

655 f"'{receiver} does not exist. " 

656 f"Input connections of {receiver_component_name} are: " 

657 + ", ".join( 

658 [f"{name} (type {_type_name(socket.type)})" for name, socket in receiver_sockets.items()] 

659 ) 

660 ) 

661 

662 # Look for a matching connection among the possible ones. 

663 # Note that if there is more than one possible connection but two sockets match by name, they're paired. 

664 sender_socket_candidates: list[OutputSocket] = ( 

665 [sender_socket] if sender_socket else list(sender_sockets.values()) 

666 ) 

667 receiver_socket_candidates: list[InputSocket] = ( 

668 [receiver_socket] if receiver_socket else list(receiver_sockets.values()) 

669 ) 

670 

671 conversion_strategy = None 

672 

673 # Find all possible connections between these two components 

674 possible_connections: list[tuple[OutputSocket, InputSocket, ConversionStrategyType]] = [] 

675 for sender_sock, receiver_sock in itertools.product(sender_socket_candidates, receiver_socket_candidates): 

676 is_compat, conversion_strategy = _types_are_compatible( 

677 sender_sock.type, receiver_sock.type, self._connection_type_validation 

678 ) 

679 if is_compat: 

680 possible_connections.append((sender_sock, receiver_sock, conversion_strategy)) 

681 

682 # If there are multiple possibilities, prioritize strict matches over convertible ones. 

683 # This ensures backward compatibility: previously, pipelines did not allow type conversion. 

684 if len(possible_connections) > 1 and self._connection_type_validation: 

685 strict_matches = [ 

686 (out_sock, in_sock, None) 

687 for out_sock, in_sock, conversion_strategy in possible_connections 

688 if conversion_strategy is None 

689 ] 

690 if strict_matches: 

691 possible_connections[:] = strict_matches 

692 

693 # We need this status for error messages, since we might need it in multiple places we calculate it here 

694 status = _connections_status( 

695 sender_node=sender_component_name, 

696 sender_sockets=sender_socket_candidates, 

697 receiver_node=receiver_component_name, 

698 receiver_sockets=receiver_socket_candidates, 

699 ) 

700 

701 if not possible_connections: 

702 # There's no possible connection between these two components 

703 if len(sender_socket_candidates) == len(receiver_socket_candidates) == 1: 

704 msg = ( 

705 f"Cannot connect '{sender_component_name}.{sender_socket_candidates[0].name}' with " 

706 f"'{receiver_component_name}.{receiver_socket_candidates[0].name}': " 

707 f"their declared input and output types do not match.\n{status}" 

708 ) 

709 else: 

710 msg = ( 

711 f"Cannot connect '{sender_component_name}' with '{receiver_component_name}': " 

712 f"no matching connections available.\n{status}" 

713 ) 

714 raise PipelineConnectError(msg) 

715 

716 if len(possible_connections) == 1: 

717 # There's only one possible connection, use it 

718 sender_socket = possible_connections[0][0] 

719 receiver_socket = possible_connections[0][1] 

720 conversion_strategy = possible_connections[0][2] 

721 

722 if len(possible_connections) > 1: 

723 # There are multiple possible connection, let's try to match them by name 

724 name_matches = [ 

725 (out_sock, in_sock, conversion_strategy_) 

726 for out_sock, in_sock, conversion_strategy_ in possible_connections 

727 if in_sock.name == out_sock.name 

728 ] 

729 if len(name_matches) != 1: 

730 # There's are either no matches or more than one, we can't pick one reliably 

731 msg = ( 

732 f"Cannot connect '{sender_component_name}' with " 

733 f"'{receiver_component_name}': more than one connection is possible " 

734 "between these components. Please specify the connection name, like: " 

735 f"pipeline.connect('{sender_component_name}.{possible_connections[0][0].name}', " 

736 f"'{receiver_component_name}.{possible_connections[0][1].name}').\n{status}" 

737 ) 

738 raise PipelineConnectError(msg) 

739 

740 # Get the only possible match 

741 sender_socket = name_matches[0][0] 

742 receiver_socket = name_matches[0][1] 

743 conversion_strategy = name_matches[0][2] 

744 

745 # Connection must be valid on both sender/receiver sides 

746 if not sender_socket or not receiver_socket or not sender_component_name or not receiver_component_name: 

747 if sender_component_name and sender_socket: 

748 sender_repr = f"{sender_component_name}.{sender_socket.name} ({_type_name(sender_socket.type)})" 

749 else: 

750 sender_repr = "input needed" 

751 

752 if receiver_component_name and receiver_socket: 

753 receiver_repr = f"({_type_name(receiver_socket.type)}) {receiver_component_name}.{receiver_socket.name}" 

754 else: 

755 receiver_repr = "output" 

756 msg = f"Connection must have both sender and receiver: {sender_repr} -> {receiver_repr}" 

757 raise PipelineConnectError(msg) 

758 

759 logger.debug( 

760 "Connecting '{sender_component}.{sender_socket_name}' to '{receiver_component}.{receiver_socket_name}'", 

761 sender_component=sender_component_name, 

762 sender_socket_name=sender_socket.name, 

763 receiver_component=receiver_component_name, 

764 receiver_socket_name=receiver_socket.name, 

765 ) 

766 

767 if receiver_component_name in sender_socket.receivers and sender_component_name in receiver_socket.senders: 

768 # This is already connected, nothing to do 

769 return self 

770 

771 if receiver_socket.senders: 

772 receiver_socket = self._make_socket_auto_variadic( 

773 component_name=receiver_component_name, receiver_socket=receiver_socket, error_type=PipelineConnectError 

774 ) 

775 

776 # Update the sockets with the new connection 

777 sender_socket.receivers.append(receiver_component_name) 

778 receiver_socket.senders.append(sender_component_name) 

779 

780 # Create the new connection 

781 self.graph.add_edge( 

782 sender_component_name, 

783 receiver_component_name, 

784 key=f"{sender_socket.name}/{receiver_socket.name}", 

785 conn_type=_type_name(sender_socket.type), 

786 from_socket=sender_socket, 

787 to_socket=receiver_socket, 

788 mandatory=receiver_socket.is_mandatory, 

789 conversion_strategy=conversion_strategy, 

790 ) 

791 return self 

792 

793 def connect_many(self, connections: list[tuple[str, str]]) -> Self: 

794 """ 

795 Connect multiple pairs of components. 

796 

797 Connections are made in the order provided. If connecting a pair raises an exception, no subsequent pairs 

798 are connected, while earlier successful connections remain in the pipeline. Repeating an existing connection 

799 is a no-op. 

800 

801 :param connections: 

802 A list of `(sender, receiver)` pairs. Each value uses the same format accepted by 

803 `Pipeline.connect()`. 

804 :returns: 

805 The Pipeline instance. 

806 

807 :raises PipelineConnectError: 

808 If a pair of components cannot be connected. 

809 :raises ValueError: 

810 If a sender or receiver component is not present in the pipeline. 

811 """ 

812 for sender, receiver in connections: 

813 self.connect(sender, receiver) 

814 

815 return self 

816 

817 def get_component(self, name: str) -> Component: 

818 """ 

819 Get the component with the specified name from the pipeline. 

820 

821 :param name: 

822 The name of the component. 

823 :returns: 

824 The instance of that component. 

825 

826 :raises ValueError: 

827 If a component with that name is not present in the pipeline. 

828 """ 

829 try: 

830 return self.graph.nodes[name]["instance"] 

831 except KeyError as exc: 

832 raise ValueError(f"Component named {name} not found in the pipeline.") from exc 

833 

834 def get_component_name(self, instance: Component) -> str: 

835 """ 

836 Returns the name of the Component instance if it has been added to this Pipeline or an empty string otherwise. 

837 

838 :param instance: 

839 The Component instance to look for. 

840 :returns: 

841 The name of the Component instance. 

842 """ 

843 for name, inst in self.graph.nodes(data="instance"): 

844 if inst == instance: 

845 return name 

846 return "" 

847 

848 def inputs(self, include_components_with_connected_inputs: bool = False) -> dict[str, dict[str, Any]]: 

849 """ 

850 Returns a dictionary containing the inputs of a pipeline. 

851 

852 Each key in the dictionary corresponds to a component name, and its value is another dictionary that describes 

853 the input sockets of that component, including their types and whether they are optional. 

854 

855 :param include_components_with_connected_inputs: 

856 If `False`, only components that have disconnected input edges are 

857 included in the output. 

858 :returns: 

859 A dictionary where each key is a pipeline component name and each value is a dictionary of 

860 inputs sockets of that component. 

861 """ 

862 inputs: dict[str, dict[str, Any]] = {} 

863 for component_name, data in find_pipeline_inputs(self.graph, include_components_with_connected_inputs).items(): 

864 sockets_description = {} 

865 for socket in data: 

866 # Variadic mandatory sockets with existing connections don't require user input, so treat them as 

867 # optional. 

868 is_mandatory = socket.is_mandatory and not socket.senders 

869 sockets_description[socket.name] = {"type": socket.type, "is_mandatory": is_mandatory} 

870 if not socket.is_mandatory: 

871 sockets_description[socket.name]["default_value"] = socket.default_value 

872 

873 if sockets_description: 

874 inputs[component_name] = sockets_description 

875 return inputs 

876 

877 def outputs(self, include_components_with_connected_outputs: bool = False) -> dict[str, dict[str, Any]]: 

878 """ 

879 Returns a dictionary containing the outputs of a pipeline. 

880 

881 Each key in the dictionary corresponds to a component name, and its value is another dictionary that describes 

882 the output sockets of that component. 

883 

884 :param include_components_with_connected_outputs: 

885 If `False`, only components that have disconnected output edges are 

886 included in the output. 

887 :returns: 

888 A dictionary where each key is a pipeline component name and each value is a dictionary of 

889 output sockets of that component. 

890 """ 

891 return { 

892 comp: {socket.name: {"type": socket.type} for socket in data} 

893 for comp, data in find_pipeline_outputs(self.graph, include_components_with_connected_outputs).items() 

894 if data 

895 } 

896 

897 def show( 

898 self, 

899 *, 

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

901 params: dict | None = None, 

902 timeout: int = 30, 

903 super_component_expansion: bool = False, 

904 ) -> None: 

905 """ 

906 Display an image representing this `Pipeline` in a Jupyter notebook. 

907 

908 This function generates a diagram of the `Pipeline` using a Mermaid server and displays it directly in 

909 the notebook. 

910 

911 :param server_url: 

912 The base URL of the Mermaid server used for rendering (default: 'https://mermaid.ink'). 

913 See https://github.com/jihchi/mermaid.ink and https://github.com/mermaid-js/mermaid-live-editor for more 

914 info on how to set up your own Mermaid server. 

915 

916 :param params: 

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

918 Supported keys: 

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

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

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

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

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

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

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

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

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

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

929 

930 :param timeout: 

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

932 

933 :param super_component_expansion: 

934 If set to True and the pipeline contains SuperComponents the diagram will show the internal structure of 

935 super-components as if they were components part of the pipeline instead of a "black-box". 

936 Otherwise, only the super-component itself will be displayed. 

937 

938 :raises PipelineDrawingError: 

939 If the function is called outside of a Jupyter notebook or if there is an issue with rendering. 

940 """ 

941 

942 if is_in_jupyter(): 

943 from IPython.display import Image, display 

944 

945 if super_component_expansion: 

946 graph, super_component_mapping = self._merge_super_component_pipelines() 

947 else: 

948 graph = self.graph 

949 super_component_mapping = None 

950 

951 image_data = _to_mermaid_image( 

952 graph, 

953 server_url=server_url, 

954 params=params, 

955 timeout=timeout, 

956 super_component_mapping=super_component_mapping, 

957 ) 

958 display(Image(image_data)) 

959 else: 

960 msg = "This method is only supported in Jupyter notebooks. Use Pipeline.draw() to save an image locally." 

961 raise PipelineDrawingError(msg) 

962 

963 def draw( 

964 self, 

965 *, 

966 path: Path, 

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

968 params: dict | None = None, 

969 timeout: int = 30, 

970 super_component_expansion: bool = False, 

971 ) -> None: 

972 """ 

973 Save an image representing this `Pipeline` to the specified file path. 

974 

975 This function generates a diagram of the `Pipeline` using the Mermaid server and saves it to the provided path. 

976 

977 :param path: 

978 The file path where the generated image will be saved. 

979 

980 :param server_url: 

981 The base URL of the Mermaid server used for rendering (default: 'https://mermaid.ink'). 

982 See https://github.com/jihchi/mermaid.ink and https://github.com/mermaid-js/mermaid-live-editor for more 

983 info on how to set up your own Mermaid server. 

984 

985 :param params: 

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

987 Supported keys: 

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

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

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

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

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

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

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

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

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

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

998 

999 :param timeout: 

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

1001 

1002 :param super_component_expansion: 

1003 If set to True and the pipeline contains SuperComponents the diagram will show the internal structure of 

1004 super-components as if they were components part of the pipeline instead of a "black-box". 

1005 Otherwise, only the super-component itself will be displayed. 

1006 

1007 :raises PipelineDrawingError: 

1008 If there is an issue with rendering or saving the image. 

1009 """ 

1010 

1011 # Before drawing we edit a bit the graph, to avoid modifying the original that is 

1012 # used for running the pipeline we copy it. 

1013 if super_component_expansion: 

1014 graph, super_component_mapping = self._merge_super_component_pipelines() 

1015 else: 

1016 graph = self.graph 

1017 super_component_mapping = None 

1018 

1019 image_data = _to_mermaid_image( 

1020 graph, 

1021 server_url=server_url, 

1022 params=params, 

1023 timeout=timeout, 

1024 super_component_mapping=super_component_mapping, 

1025 ) 

1026 Path(path).write_bytes(image_data) 

1027 

1028 def walk(self) -> Iterator[tuple[str, Component]]: 

1029 """ 

1030 Visits each component in the pipeline exactly once and yields its name and instance. 

1031 

1032 No guarantees are provided on the visiting order. 

1033 

1034 :returns: 

1035 An iterator of tuples of component name and component instance. 

1036 """ 

1037 for component_name, instance in self.graph.nodes(data="instance"): # noqa: UP028 

1038 yield component_name, instance 

1039 

1040 def warm_up(self) -> None: 

1041 """ 

1042 Make sure all components are warm. 

1043 

1044 It's the component's responsibility to make sure this method can be called at every `Pipeline.run()` 

1045 without re-initializing everything. 

1046 """ 

1047 for component_name in self.graph.nodes: 

1048 if hasattr(self.graph.nodes[component_name]["instance"], "warm_up"): 

1049 logger.info("Warming up component {component_name}...", component_name=component_name) 

1050 self.graph.nodes[component_name]["instance"].warm_up() 

1051 

1052 async def warm_up_async(self) -> None: 

1053 """ 

1054 Make sure all components are warm, using the async warm-up path where available. 

1055 

1056 Each component is warmed up with `warm_up_async` if it has one, otherwise with its sync `warm_up`. 

1057 Both run on the event loop, never offloaded to a worker thread. 

1058 This ensures that if an async client is created during `warm-up` (residual scenario), it binds to the loop that 

1059 `run_async` will use. 

1060 """ 

1061 for component_name in self.graph.nodes: 

1062 instance = self.graph.nodes[component_name]["instance"] 

1063 if hasattr(instance, "warm_up_async"): 

1064 logger.info("Warming up component {component_name}...", component_name=component_name) 

1065 await instance.warm_up_async() 

1066 elif hasattr(instance, "warm_up"): 

1067 logger.info("Warming up component {component_name}...", component_name=component_name) 

1068 instance.warm_up() 

1069 

1070 def close(self) -> None: 

1071 """ 

1072 Release resources held by the pipeline's components by calling each component's `close` method. 

1073 

1074 Only the synchronous side of each component is released here; use `close_async` to release async clients. 

1075 """ 

1076 for component_name in self.graph.nodes: 

1077 instance = self.graph.nodes[component_name]["instance"] 

1078 if hasattr(instance, "close"): 

1079 logger.info("Closing component {component_name}...", component_name=component_name) 

1080 instance.close() 

1081 

1082 async def close_async(self) -> None: 

1083 """ 

1084 Release resources held by the pipeline's components, using the async close path where available. 

1085 

1086 Each component is closed with `close_async` if it has one, otherwise with its sync `close`. 

1087 """ 

1088 for component_name in self.graph.nodes: 

1089 instance = self.graph.nodes[component_name]["instance"] 

1090 if hasattr(instance, "close_async"): 

1091 logger.info("Closing component {component_name}...", component_name=component_name) 

1092 await instance.close_async() 

1093 elif hasattr(instance, "close"): 

1094 logger.info("Closing component {component_name}...", component_name=component_name) 

1095 instance.close() 

1096 

1097 @staticmethod 

1098 def _create_component_span( 

1099 component_name: str, instance: Component, inputs: dict[str, Any], parent_span: tracing.Span | None = None 

1100 ) -> ContextManager[tracing.Span]: 

1101 return tracing.tracer.trace( 

1102 "haystack.component.run", 

1103 tags={ 

1104 "haystack.component.name": component_name, 

1105 "haystack.component.type": instance.__class__.__name__, 

1106 "haystack.component.fully_qualified_type": generate_qualified_class_name(type(instance)), 

1107 "haystack.component.input_types": {k: type(v).__name__ for k, v in inputs.items()}, 

1108 "haystack.component.input_spec": { 

1109 key: { 

1110 "type": value.type.__name__ if type(value.type) is type else str(value.type), 

1111 "senders": value.senders, 

1112 } 

1113 for key, value in instance.__haystack_input__._sockets_dict.items() # type: ignore 

1114 }, 

1115 "haystack.component.output_spec": { 

1116 key: { 

1117 "type": value.type.__name__ if type(value.type) is type else str(value.type), 

1118 "receivers": value.receivers, 

1119 } 

1120 for key, value in instance.__haystack_output__._sockets_dict.items() # type: ignore 

1121 }, 

1122 }, 

1123 parent_span=parent_span, 

1124 ) 

1125 

1126 def validate_input(self, data: dict[str, Any]) -> None: 

1127 """ 

1128 Validates pipeline input data. 

1129 

1130 Validates that data: 

1131 * Each Component name actually exists in the Pipeline 

1132 * Each Component is not missing any input 

1133 * Each Component has only one input per input socket, if not variadic 

1134 * Each Component doesn't receive inputs that are already sent by another Component 

1135 

1136 :param data: 

1137 A dictionary of inputs for the pipeline's components. Each key is a component name. 

1138 

1139 :raises ValueError: 

1140 If inputs are invalid according to the above. 

1141 """ 

1142 for component_name, component_inputs in data.items(): 

1143 # Check that the component exists 

1144 if component_name not in self.graph.nodes: 

1145 raise ValueError(f"Component named '{component_name}' not found in the pipeline.") 

1146 # Check that no input is provided that the component can't accept 

1147 instance = self.graph.nodes[component_name]["instance"] 

1148 for input_name in component_inputs.keys(): 

1149 if input_name not in instance.__haystack_input__._sockets_dict: 

1150 raise ValueError(f"Input '{input_name}' not found in component '{component_name}'.") 

1151 

1152 for component_name in self.graph.nodes: 

1153 instance = self.graph.nodes[component_name]["instance"] 

1154 for socket_name, socket in instance.__haystack_input__._sockets_dict.items(): 

1155 component_inputs = data.get(component_name, {}) 

1156 # Check that no mandatory input is missing for any component in the pipeline 

1157 if socket.senders == [] and socket.is_mandatory and socket_name not in component_inputs: 

1158 raise ValueError(f"Missing mandatory input '{socket_name}' for component '{component_name}'.") 

1159 

1160 # Check if an input is provided more than once for non-variadic sockets 

1161 if socket.senders and socket_name in component_inputs: 

1162 self._make_socket_auto_variadic( 

1163 component_name=component_name, receiver_socket=socket, error_type=ValueError 

1164 ) 

1165 

1166 def _make_socket_auto_variadic( 

1167 self, component_name: str, receiver_socket: InputSocket, error_type: type[Exception] 

1168 ) -> InputSocket: 

1169 """ 

1170 Attempts to make the receiver socket lazy variadic in-place to accommodate a new sender. 

1171 

1172 A socket is automatically made lazy variadic when: 

1173 - It already has at least one connected sender 

1174 - It is not already variadic 

1175 - Its type is list, Optional[list], a union of list types 

1176 

1177 When auto-variadicity is applied, `wrap_input_in_list` is also set to False so that sender output types match 

1178 the receiver socket's declared list type directly. 

1179 

1180 :param component_name: 

1181 Name of the component owning the receiver socket, used in error messages. 

1182 :param receiver_socket: 

1183 The receiver socket to inspect and potentially modify in-place. 

1184 :param error_type: 

1185 Exception class to raise on failure (e.g. ValueError or PipelineConnectError). 

1186 :returns: 

1187 The (possibly modified) receiver socket. 

1188 :raises error_type: 

1189 If the socket cannot accept multiple senders given its type constraints. 

1190 """ 

1191 # If it's already variadic, we return as-is 

1192 if receiver_socket.is_variadic: 

1193 return receiver_socket 

1194 

1195 # Get receiver origin 

1196 receiver_origin = _safe_get_origin(receiver_socket.type) 

1197 

1198 # Handle Union types 

1199 if receiver_origin == Union: 

1200 # Unwrap Optional types 

1201 non_none_args = [a for a in get_args(receiver_socket.type) if a is not type(None)] 

1202 if len(non_none_args) == 1: 

1203 receiver_origin = _safe_get_origin(non_none_args[0]) 

1204 # Handle Union of list types (e.g. list[int] | list[str]) 

1205 elif all(_safe_get_origin(arg) == list for arg in non_none_args): 

1206 receiver_origin = list 

1207 

1208 # If the receiver origin is a list, we can make the socket lazy variadic 

1209 if receiver_origin == list: 

1210 receiver_socket.is_lazy_variadic = True 

1211 receiver_socket.wrap_input_in_list = False 

1212 return receiver_socket 

1213 

1214 raise error_type( 

1215 f"Component '{component_name}' cannot accept multiple inputs to '{receiver_socket.name}'. " 

1216 f"It is already connected to component '{receiver_socket.senders[0]}', and it can only accept " 

1217 f"inputs from multiple senders if its type is list, Optional[list], or union of list types." 

1218 ) 

1219 

1220 def _prepare_component_input_data(self, data: dict[str, Any]) -> dict[str, dict[str, Any]]: 

1221 """ 

1222 Prepares input data for pipeline components. 

1223 

1224 Organizes input data for pipeline components and identifies any inputs that are not matched to any 

1225 component's input slots. Deep-copies data items to avoid sharing mutables across multiple components. 

1226 

1227 This method processes a flat dictionary of input data, where each key-value pair represents an input name 

1228 and its corresponding value. It distributes these inputs to the appropriate pipeline components based on 

1229 their input requirements. Inputs that don't match any component's input slots are classified as unresolved. 

1230 

1231 :param data: 

1232 A dictionary potentially having input names as keys and input values as values. 

1233 

1234 :returns: 

1235 A dictionary mapping component names to their respective matched inputs. 

1236 """ 

1237 # check whether the data is a nested dictionary of component inputs where each key is a component name 

1238 # and each value is a dictionary of input parameters for that component 

1239 is_nested_component_input = all(isinstance(value, dict) for value in data.values()) 

1240 if not is_nested_component_input: 

1241 # flat input, a dict where keys are input names and values are the corresponding values 

1242 # we need to convert it to a nested dictionary of component inputs and then run the pipeline 

1243 # just like in the previous case 

1244 pipeline_input_data: dict[str, dict[str, Any]] = defaultdict(dict) 

1245 unresolved_kwargs = {} 

1246 

1247 # Retrieve the input slots for each component in the pipeline 

1248 available_inputs: dict[str, dict[str, Any]] = self.inputs() 

1249 

1250 # Go through all provided to distribute them to the appropriate component inputs 

1251 for input_name, input_value in data.items(): 

1252 resolved_at_least_once = False 

1253 

1254 # Check each component to see if it has a slot for the current kwarg 

1255 for component_name, component_inputs in available_inputs.items(): 

1256 if input_name in component_inputs: 

1257 # If a match is found, add the kwarg to the component's input data 

1258 pipeline_input_data[component_name][input_name] = input_value 

1259 resolved_at_least_once = True 

1260 

1261 if not resolved_at_least_once: 

1262 unresolved_kwargs[input_name] = input_value 

1263 

1264 if unresolved_kwargs: 

1265 logger.warning( 

1266 "Inputs {input_keys} were not matched to any component inputs, please check your run parameters.", 

1267 input_keys=list(unresolved_kwargs.keys()), 

1268 ) 

1269 

1270 data = dict(pipeline_input_data) 

1271 

1272 # deepcopying the inputs prevents the Pipeline run logic from being altered unexpectedly 

1273 # when the same input reference is passed to multiple components. 

1274 for component_name, component_inputs in data.items(): 

1275 data[component_name] = {k: _deepcopy_with_exceptions(v) for k, v in component_inputs.items()} 

1276 

1277 return data 

1278 

1279 def _find_receivers_from( 

1280 self, component_name: str 

1281 ) -> list[tuple[str, OutputSocket, InputSocket, ConversionStrategyType]]: 

1282 """ 

1283 Utility function to find all Components that receive input from `component_name`. 

1284 

1285 :param component_name: 

1286 Name of the sender Component 

1287 

1288 :returns: A list of tuples containing: 

1289 - receiver component name 

1290 - sender OutputSocket 

1291 - receiver InputSocket 

1292 - ConversionStrategy if conversion is required, otherwise None. 

1293 """ 

1294 res = [] 

1295 for _, receiver_name, connection in self.graph.edges(nbunch=component_name, data=True): 

1296 sender_socket: OutputSocket = connection["from_socket"] 

1297 receiver_socket: InputSocket = connection["to_socket"] 

1298 res.append((receiver_name, sender_socket, receiver_socket, connection.get("conversion_strategy"))) 

1299 return res 

1300 

1301 @staticmethod 

1302 def _convert_to_internal_format(pipeline_inputs: dict[str, Any]) -> InputsType: 

1303 """ 

1304 Converts the inputs to the pipeline to the format that is needed for the internal `Pipeline.run` logic. 

1305 

1306 Example Input: 

1307 {'prompt_builder': {'question': 'Who lives in Paris?'}, 'retriever': {'query': 'Who lives in Paris?'}} 

1308 Example Output: 

1309 {'prompt_builder': {'question': [{'sender': None, 'value': 'Who lives in Paris?'}]}, 

1310 'retriever': {'query': [{'sender': None, 'value': 'Who lives in Paris?'}]}} 

1311 

1312 :param pipeline_inputs: Inputs to the pipeline. 

1313 :returns: Converted inputs that can be used by the internal `Pipeline.run` logic. 

1314 """ 

1315 inputs: InputsType = {} 

1316 for component_name, socket_dict in pipeline_inputs.items(): 

1317 inputs[component_name] = {} 

1318 for socket_name, value in socket_dict.items(): 

1319 inputs[component_name][socket_name] = [{"sender": None, "value": value}] 

1320 

1321 return inputs 

1322 

1323 @staticmethod 

1324 def _consume_component_inputs( 

1325 component_name: str, component: dict, inputs: InputsType, is_resume: bool = False 

1326 ) -> dict[str, Any]: 

1327 """ 

1328 Extracts the inputs needed to run for the component and removes them from the global inputs state. 

1329 

1330 :param component_name: The name of a component. 

1331 :param component: Component with component metadata. 

1332 :param inputs: Global inputs state. 

1333 :param is_resume: Whether the component is being resumed from a breakpoint. If True, the inputs 

1334 have already been consumed, so the first available value is returned for each socket. 

1335 :returns: The inputs for the component. 

1336 """ 

1337 component_inputs = inputs.get(component_name, {}) 

1338 consumed_inputs = {} 

1339 greedy_inputs_to_remove = set() 

1340 for socket_name, socket in component["input_sockets"].items(): 

1341 socket_inputs = component_inputs.get(socket_name, []) 

1342 socket_inputs_values = [ 

1343 sock["value"] for sock in socket_inputs if not isinstance(sock["value"], _NoOutputProduced) 

1344 ] 

1345 

1346 # if we are resuming a component, the inputs are already consumed, so we just return the first input 

1347 if is_resume: 

1348 consumed_inputs[socket_name] = socket_inputs_values[0] 

1349 continue 

1350 

1351 if socket_inputs_values: 

1352 if socket.is_greedy: 

1353 # We need to keep track of greedy inputs because we always remove them, even if they come from 

1354 # outside the pipeline. Otherwise, a greedy input from the user would trigger a pipeline to run 

1355 # indefinitely. 

1356 greedy_inputs_to_remove.add(socket_name) 

1357 consumed_inputs[socket_name] = [socket_inputs_values[0]] 

1358 elif socket.is_lazy_variadic: 

1359 if socket.wrap_input_in_list: 

1360 # We use all inputs provided to the socket on a lazy variadic socket. 

1361 # So keep it wrapped in a list. 

1362 consumed_inputs[socket_name] = socket_inputs_values 

1363 else: 

1364 # We flatten one-level of lists for lazy variadic sockets that don't wrap inputs in lists. 

1365 # This way the incoming inputs match the expected type of the socket. 

1366 consumed_inputs[socket_name] = list(itertools.chain.from_iterable(socket_inputs_values)) 

1367 else: 

1368 # For a normal socket we only care about the first input provided to the socket. 

1369 consumed_inputs[socket_name] = socket_inputs_values[0] 

1370 

1371 # We prune all inputs except for those that were provided from outside the pipeline (e.g. user inputs). 

1372 pruned_inputs = { 

1373 socket_name: [ 

1374 sock for sock in socket if sock["sender"] is None and socket_name not in greedy_inputs_to_remove 

1375 ] 

1376 for socket_name, socket in component_inputs.items() 

1377 } 

1378 pruned_inputs = {socket_name: socket for socket_name, socket in pruned_inputs.items() if len(socket) > 0} 

1379 

1380 inputs[component_name] = pruned_inputs 

1381 

1382 return consumed_inputs 

1383 

1384 def _fill_queue( 

1385 self, component_names: list[str], inputs: InputsType, component_visits: dict[str, int] 

1386 ) -> FIFOPriorityQueue: 

1387 """ 

1388 Calculates the execution priority for each component and inserts it into the priority queue. 

1389 

1390 :param component_names: Names of the components to put into the queue. 

1391 :param inputs: Inputs to the components. 

1392 :param component_visits: Current state of component visits. 

1393 :returns: A prioritized queue of component names. 

1394 """ 

1395 priority_queue = FIFOPriorityQueue() 

1396 for component_name in component_names: 

1397 comp = self._get_component_with_graph_metadata_and_visits(component_name, component_visits[component_name]) 

1398 priority = self._calculate_priority(comp, inputs.get(component_name, {})) 

1399 priority_queue.push(component_name, priority) 

1400 return priority_queue 

1401 

1402 @staticmethod 

1403 def _calculate_priority(comp: dict, comp_inputs: dict[str, list[dict[str, Any]]]) -> ComponentPriority: 

1404 """ 

1405 Calculates the execution priority for a component depending on the component's inputs. 

1406 

1407 :param comp: Component metadata and component instance. 

1408 :param comp_inputs: Inputs to the component. 

1409 :returns: Priority value for the component. 

1410 """ 

1411 # NOTE: Even if a component can run, it doesn't mean it's ready to run since it could be waiting for optional 

1412 # inputs. This is why it's only used to determine if a component is BLOCKED or not. 

1413 if not can_component_run(comp, comp_inputs): 

1414 return ComponentPriority.BLOCKED 

1415 if is_any_greedy_socket_ready(comp, comp_inputs) and are_all_sockets_ready(comp, comp_inputs): 

1416 # This priority is explicitly used in the async run path + implicitly in _is_queue_stale 

1417 # Implicit b/c it checks via ">" operator if there is a component with HIGHEST priority 

1418 return ComponentPriority.HIGHEST 

1419 if all_predecessors_executed(comp, comp_inputs): 

1420 # This priority is explicitly used in the async run path + in _is_queue_stale 

1421 return ComponentPriority.READY 

1422 # If we make it here it means the component can run but is waiting for more inputs, so we give it the lowest 

1423 # priority. This way, components that are ready to run will be prioritized over ones assigned with this prio. 

1424 return ComponentPriority.DEFER 

1425 

1426 def _get_component_with_graph_metadata_and_visits(self, component_name: str, visits: int) -> dict[str, Any]: 

1427 """ 

1428 Returns the component instance alongside input/output-socket metadata from the graph and adds current visits. 

1429 

1430 We can't store visits in the pipeline graph because this would prevent reentrance / thread-safe execution. 

1431 

1432 :param component_name: The name of the component. 

1433 :param visits: Number of visits for the component. 

1434 :returns: dict with keys: 

1435 - instance: the component instance 

1436 - input_sockets: the component input sockets metadata from the graph 

1437 - output_sockets: the component output sockets metadata from the graph 

1438 """ 

1439 comp_dict = self.graph.nodes[component_name] 

1440 # We inject the visits into the component dict here to avoid storing it in the graph, which would prevent 

1441 # thread-safe execution 

1442 return {**comp_dict, "visits": visits} 

1443 

1444 def _get_next_runnable_component( 

1445 self, priority_queue: FIFOPriorityQueue, component_visits: dict[str, int] 

1446 ) -> tuple[ComponentPriority, str, dict[str, Any]] | None: 

1447 """ 

1448 Returns the next runnable component alongside its metadata from the priority queue. 

1449 

1450 :param priority_queue: Priority queue of component names. 

1451 :param component_visits: Current state of component visits. 

1452 :returns: The next runnable component, the component name, and its priority 

1453 or None if no component in the queue can run. 

1454 :raises: PipelineMaxComponentRuns if the next runnable component has exceeded the maximum number of runs. 

1455 """ 

1456 item = priority_queue.get() 

1457 

1458 # If no component is runnable, return None 

1459 if item is None: 

1460 return None 

1461 

1462 component_name = item[1] 

1463 comp = self._get_component_with_graph_metadata_and_visits(component_name, component_visits[component_name]) 

1464 # Only raise the max run count error if the component is not blocked, since if it's blocked it means it 

1465 # can't run anyway. 

1466 if item[0] < ComponentPriority.BLOCKED and comp["visits"] >= self._max_runs_per_component: 

1467 msg = f"Maximum run count {self._max_runs_per_component} reached for component '{component_name}'" 

1468 raise PipelineMaxComponentRuns(msg) 

1469 return ComponentPriority(item[0]), component_name, comp 

1470 

1471 @staticmethod 

1472 def _add_missing_input_defaults( 

1473 component_inputs: dict[str, list[dict[str, Any]]], component_input_sockets: dict[str, InputSocket] 

1474 ) -> dict[str, Any]: 

1475 """ 

1476 Updates the inputs with the default values for the inputs that are missing 

1477 

1478 :param component_inputs: Inputs for the component. 

1479 :param component_input_sockets: Input sockets of the component. 

1480 """ 

1481 for name, socket in component_input_sockets.items(): 

1482 if not socket.is_mandatory and name not in component_inputs: 

1483 # NOTE: Variadic inputs expect a single default value in the function signature that matches the inner 

1484 # type, for example Variadic[str] = "default". When executed inside a pipeline, we wrap this 

1485 # default into a list, resulting in ["default"], which is the intended behavior. 

1486 # 

1487 # However, when the component is executed directly by calling run(), the default is not wrapped and 

1488 # is treated as an iterable. 

1489 # For strings, this would produce ["d", "e", "f", ...] instead of ["default"]. 

1490 if socket.is_variadic: 

1491 component_inputs[name] = [socket.default_value] 

1492 else: 

1493 component_inputs[name] = socket.default_value 

1494 

1495 return component_inputs 

1496 

1497 def _topological_sort(self) -> dict[str, int]: 

1498 """ 

1499 Returns a topological sort of the components in the pipeline. 

1500 

1501 If the graph is a DAG, we use lexicographical topological sort to get a deterministic order of the components. 

1502 If the graph is not a DAG, we use the condensation of the graph to get a topological sort of the strongly 

1503 connected components. This way, components that are part of the same cycle will have the same priority and will 

1504 be tie-broken by their name in lexicographical order, while components that are not part of the same cycle will 

1505 be tie-broken by their topological order. 

1506 

1507 :returns: 

1508 A dictionary mapping component names to their position in the topological sort. 

1509 """ 

1510 if networkx.is_directed_acyclic_graph(self.graph): 

1511 topological_sort = networkx.lexicographical_topological_sort(self.graph) 

1512 return {node: idx for idx, node in enumerate(topological_sort)} 

1513 else: 

1514 condensed = networkx.condensation(self.graph) 

1515 condensed_sorted = {node: idx for idx, node in enumerate(networkx.topological_sort(condensed))} 

1516 return { 

1517 component_name: condensed_sorted[node] for component_name, node in condensed.graph["mapping"].items() 

1518 } 

1519 

1520 def _tiebreak_waiting_components( 

1521 self, 

1522 component_name: str, 

1523 priority: ComponentPriority, 

1524 priority_queue: FIFOPriorityQueue, 

1525 topological_sort: dict[str, int] | None, 

1526 ) -> tuple[str, dict[str, int] | None]: 

1527 """ 

1528 Decides which component to run when multiple components are waiting for inputs with the same priority. 

1529 

1530 NOTE: This was designed to only tie-break for components with the priority DEFER. Since this function also 

1531 removes these components from the priority queue we rely on _is_queue_stale to then refill the priority queue. 

1532 And _is_queue_stale only triggers when all remaining components have BLOCKED priority. 

1533 

1534 :param component_name: The name of the component. 

1535 :param priority: Priority of the component. 

1536 :param priority_queue: Priority queue of component names. 

1537 :param topological_sort: Cached topological sort of all components in the pipeline. 

1538 

1539 :returns: 

1540 The name of the component to run and the cached topological sort of all components in the pipeline. 

1541 """ 

1542 # Create a list of all components that have the same priority as the current component, including the 

1543 # current component itself and remove them from the priority queue. 

1544 components_with_same_priority = [component_name] 

1545 while len(priority_queue) > 0: 

1546 next_priority, next_component_name = priority_queue.peek() 

1547 if priority == ComponentPriority.DEFER and next_priority == ComponentPriority.DEFER: 

1548 priority_queue.pop() # actually remove the component 

1549 components_with_same_priority.append(next_component_name) 

1550 else: 

1551 break 

1552 

1553 # If there are multiple components with the same priority, we tiebreak them to decide which one to run first. 

1554 if len(components_with_same_priority) > 1: 

1555 topological_sort = topological_sort or self._topological_sort() 

1556 components_with_same_priority = sorted( 

1557 components_with_same_priority, key=lambda comp_name: (topological_sort[comp_name], comp_name.lower()) 

1558 ) 

1559 component_name = components_with_same_priority[0] 

1560 

1561 return component_name, topological_sort 

1562 

1563 def _find_components_blocking_pipeline( 

1564 self, priority_queue: FIFOPriorityQueue, component_visits: dict[str, int], inputs: InputsType 

1565 ) -> tuple[list[str], list[str]]: 

1566 """ 

1567 Finds the components that are most likely blocking the pipeline execution. 

1568 

1569 :returns: 

1570 The list of component names that are most likely blocking the pipeline execution and their corresponding 

1571 component types. 

1572 """ 

1573 # 1. Go through all components in priority queue (should all be blocked at this point) 

1574 comps_in_queue: list[str] = [comp_name for _, _, comp_name in priority_queue._queue] 

1575 

1576 # 2. Check which components have entries in inputs. 

1577 comps_with_inputs = [] 

1578 for comp_name in comps_in_queue: 

1579 # If component has non-empty inputs and is blocked it means that the component is waiting for more inputs 

1580 # to run, so it could be blocking the pipeline. 

1581 if inputs.get(comp_name): 

1582 comps_with_inputs.append(comp_name) 

1583 

1584 # If there are no components with any inputs we fallback to checking all components in the queue. 

1585 # This isn't always ideal since already executed components can also be in the queue at this point also 

1586 # with blocked priority. 

1587 if not comps_with_inputs: 

1588 comps_with_inputs = comps_in_queue 

1589 

1590 # 3. Only keep components with the lowest number of visits. Mostly needed to handle the fallback case if no 

1591 # components with inputs are found. 

1592 ordered_comps_with_inputs = sorted(comps_with_inputs, key=lambda x: component_visits[x]) 

1593 lowest_component_visit = component_visits[ordered_comps_with_inputs[0]] 

1594 possible_blocking_comps = [ 

1595 comp for comp in ordered_comps_with_inputs if component_visits[comp] == lowest_component_visit 

1596 ] 

1597 

1598 # If there is only one component with the lowest visits, return it as the most likely blocking component. 

1599 if len(possible_blocking_comps) == 1: 

1600 blocking_comp_types = [self.graph.nodes[possible_blocking_comps[0]]["instance"].__class__.__name__] 

1601 self._log_warning_for_blocking_components( 

1602 blocking_comp_names=possible_blocking_comps, blocking_comp_types=blocking_comp_types 

1603 ) 

1604 return possible_blocking_comps, blocking_comp_types 

1605 

1606 # 4. Then for all components with the same lowest component visits we sort topologically before returning. 

1607 topological_sort = self._topological_sort() 

1608 possible_blocking_comps = sorted( 

1609 possible_blocking_comps, key=lambda comp_name: (topological_sort[comp_name], comp_name.lower()) 

1610 ) 

1611 possible_blocking_comp_types = [ 

1612 self.graph.nodes[comp_name]["instance"].__class__.__name__ for comp_name in possible_blocking_comps 

1613 ] 

1614 

1615 self._log_warning_for_blocking_components( 

1616 blocking_comp_names=possible_blocking_comps, blocking_comp_types=possible_blocking_comp_types 

1617 ) 

1618 return possible_blocking_comps, possible_blocking_comp_types 

1619 

1620 def _log_warning_for_blocking_components( 

1621 self, blocking_comp_names: list[str], blocking_comp_types: list[dict] 

1622 ) -> None: 

1623 """ 

1624 Logs a warning about the components that are most likely blocking the pipeline execution. 

1625 

1626 :param blocking_comp_names: The list of component names that are most likely blocking the pipeline execution. 

1627 :param blocking_comp_types: The list of component types that are most likely blocking the pipeline execution. 

1628 """ 

1629 comp_details = "\n".join( 

1630 f" - '{name}' ({comp_type})" 

1631 for name, comp_type in zip(blocking_comp_names, blocking_comp_types, strict=True) 

1632 ) 

1633 logger.warning( 

1634 "Cannot run pipeline - the pipeline appears to be blocked.\n" 

1635 "The following components could not be run and may be waiting on inputs that will " 

1636 "never arrive:\n" + comp_details + "\n" 

1637 "Note that some of these components may be intentionally inactive due to conditional " 

1638 "branching. If this is unexpected, check the connections to these components and " 

1639 "ensure all required inputs are provided.", 

1640 component_names=blocking_comp_names, 

1641 component_types=blocking_comp_types, 

1642 ) 

1643 

1644 def _write_component_outputs( 

1645 self, 

1646 *, 

1647 component_name: str, 

1648 component_outputs: Mapping[str, Any], 

1649 inputs: InputsType, 

1650 receivers: Sequence[tuple[str, OutputSocket, InputSocket, ConversionStrategyType]], 

1651 include_outputs_from: set[str], 

1652 ) -> Mapping[str, Any]: 

1653 """ 

1654 Distributes the outputs of a component to the input sockets that it is connected to. 

1655 

1656 :param component_name: The name of the component. 

1657 :param component_outputs: The outputs of the component. 

1658 :param inputs: The current global input state. 

1659 :param receivers: A sequence of tuples containing: 

1660 - receiver component name, 

1661 - output socket of the sender, 

1662 - input socket of the receiver, 

1663 - ConversionStrategy to be used to convert the value if required, otherwise None. 

1664 :param include_outputs_from: Set of component names that should always return an output from the pipeline. 

1665 """ 

1666 for receiver_name, sender_socket, receiver_socket, conversion_strategy in receivers: 

1667 # We either get the value that was produced by the actor or we use a _NoOutputProduced marker to indicate 

1668 # that the sender did not produce an output for this socket. 

1669 # This allows us to track if a predecessor already ran but did not produce an output. 

1670 value = component_outputs.get(sender_socket.name, _NoOutputProduced()) 

1671 

1672 if not isinstance(value, _NoOutputProduced) and conversion_strategy: 

1673 try: 

1674 value = _convert_value(value=value, conversion_strategy=conversion_strategy) 

1675 except Exception as e: 

1676 sender_node = self.graph.nodes.get(component_name) 

1677 sender_instance = sender_node.get("instance") if sender_node else None 

1678 sender_type_name = type(sender_instance).__name__ if sender_instance else "unknown" 

1679 

1680 receiver_node = self.graph.nodes.get(receiver_name) 

1681 receiver_instance = receiver_node.get("instance") if receiver_node else None 

1682 receiver_type_name = type(receiver_instance).__name__ if receiver_instance else "unknown" 

1683 

1684 msg = ( 

1685 f"Failed to perform conversion between components:\n" 

1686 f"Sender component: '{component_name}' (type: '{sender_type_name}')\n" 

1687 f"Sender socket: '{sender_socket.name}'\n" 

1688 f"Receiver component: '{receiver_name}' (type: '{receiver_type_name}')\n" 

1689 f"Receiver socket: '{receiver_socket.name}'\n" 

1690 f"Error: {e}" 

1691 ) 

1692 raise PipelineRuntimeError(component_name=None, component_type=None, message=msg) from e 

1693 

1694 if receiver_name not in inputs: 

1695 inputs[receiver_name] = {} 

1696 

1697 if receiver_socket.is_lazy_variadic: 

1698 # If the receiver socket is lazy variadic, we append the new input. 

1699 # Lazy variadic sockets can collect multiple inputs. 

1700 _write_to_lazy_variadic_socket( 

1701 inputs=inputs, 

1702 receiver_name=receiver_name, 

1703 receiver_socket_name=receiver_socket.name, 

1704 component_name=component_name, 

1705 value=value, 

1706 ) 

1707 else: 

1708 # If the receiver socket is not lazy variadic, it is greedy variadic or non-variadic. 

1709 # We overwrite with the new input if it's not a _NoOutputProduced marker, or if the current value 

1710 # is None. 

1711 _write_to_standard_socket( 

1712 inputs=inputs, 

1713 receiver_name=receiver_name, 

1714 receiver_socket_name=receiver_socket.name, 

1715 component_name=component_name, 

1716 value=value, 

1717 ) 

1718 

1719 # If we want to include all outputs from this actor in the final outputs, we don't need to prune any consumed 

1720 # outputs 

1721 if component_name in include_outputs_from: 

1722 return component_outputs 

1723 

1724 # We prune outputs that were consumed by any receiving sockets. 

1725 # All remaining outputs will be added to the final outputs of the pipeline. 

1726 consumed_outputs = {sender_socket.name for _, sender_socket, __, ___ in receivers} 

1727 return {key: value for key, value in component_outputs.items() if key not in consumed_outputs} 

1728 

1729 @staticmethod 

1730 def _is_queue_stale(priority_queue: FIFOPriorityQueue) -> bool: 

1731 """ 

1732 Checks if the priority queue needs to be recomputed because the priorities might have changed. 

1733 

1734 The queue is considered stale if it is empty or if the highest priority component is not READY or HIGHEST. 

1735 

1736 For example, if the next component in a queue has the priority READY then the equality becomes 

1737 ComponentPriority.READY > ComponentPriority.READY which is false. 

1738 However, if the next component has priority DEFER (or BLOCKED) then the equality becomes 

1739 ComponentPriority.DEFER > ComponentPriority.READY which is true, indicating that the queue is stale. 

1740 

1741 :param priority_queue: Priority queue of component names. 

1742 """ 

1743 return len(priority_queue) == 0 or priority_queue.peek()[0] > ComponentPriority.READY 

1744 

1745 @staticmethod 

1746 def validate_pipeline(priority_queue: FIFOPriorityQueue) -> None: 

1747 """ 

1748 Validate the pipeline to check if it is blocked or has no valid entry point. 

1749 

1750 :param priority_queue: Priority queue of component names. 

1751 :raises PipelineRuntimeError: 

1752 If the pipeline is blocked or has no valid entry point. 

1753 """ 

1754 if len(priority_queue) == 0: 

1755 return 

1756 

1757 candidate = priority_queue.peek() 

1758 if candidate is not None and candidate[0] == ComponentPriority.BLOCKED: 

1759 raise PipelineComponentsBlockedError() 

1760 

1761 def _find_super_components(self) -> list[tuple[str, Component]]: 

1762 """ 

1763 Find all SuperComponents in the pipeline. 

1764 

1765 :returns: 

1766 List of tuples containing (component_name, component_instance) representing a SuperComponent. 

1767 """ 

1768 

1769 super_components = [] 

1770 for comp_name, comp in self.walk(): 

1771 # a SuperComponent has a "pipeline" attribute which itself a Pipeline instance 

1772 # we don't test against SuperComponent because doing so always lead to circular imports 

1773 if hasattr(comp, "pipeline") and isinstance(comp.pipeline, self.__class__): 

1774 super_components.append((comp_name, comp)) 

1775 return super_components 

1776 

1777 def _merge_super_component_pipelines(self) -> tuple[networkx.MultiDiGraph, dict[str, str]]: 

1778 """ 

1779 Merge the internal pipelines of SuperComponents into the main pipeline graph structure. 

1780 

1781 This creates a new networkx.MultiDiGraph containing all the components from both the main pipeline 

1782 and all the internal SuperComponents' pipelines. The SuperComponents are removed and their internal 

1783 components are connected to corresponding input and output sockets of the main pipeline. 

1784 

1785 :returns: 

1786 A tuple containing: 

1787 - A networkx.MultiDiGraph with the expanded structure of the main pipeline and all it's SuperComponents 

1788 - A dictionary mapping component names to boolean indicating that this component was part of a 

1789 SuperComponent 

1790 - A dictionary mapping component names to their SuperComponent name 

1791 """ 

1792 merged_graph = self.graph.copy() 

1793 super_component_mapping: dict[str, str] = {} 

1794 

1795 for super_name, super_component in self._find_super_components(): 

1796 internal_pipeline = super_component.pipeline # type: ignore 

1797 internal_graph = internal_pipeline.graph.copy() 

1798 

1799 # Mark all components in the internal pipeline as being part of a SuperComponent 

1800 for node in internal_graph.nodes(): 

1801 super_component_mapping[node] = super_name 

1802 

1803 # edges connected to the super component 

1804 incoming_edges = list(merged_graph.in_edges(super_name, data=True)) 

1805 outgoing_edges = list(merged_graph.out_edges(super_name, data=True)) 

1806 

1807 # merge the SuperComponent graph into the main graph and remove the super component node 

1808 # since its components are now part of the main graph 

1809 merged_graph = networkx.compose(merged_graph, internal_graph) 

1810 merged_graph.remove_node(super_name) 

1811 

1812 # get the entry and exit points of the SuperComponent internal pipeline 

1813 entry_points = [n for n in internal_graph.nodes() if internal_graph.in_degree(n) == 0] 

1814 exit_points = [n for n in internal_graph.nodes() if internal_graph.out_degree(n) == 0] 

1815 

1816 # connect the incoming edges to entry points 

1817 for sender, _, edge_data in incoming_edges: 

1818 sender_socket = edge_data["from_socket"] 

1819 for entry_point in entry_points: 

1820 # find a matching input socket in the entry point 

1821 entry_point_sockets = internal_graph.nodes[entry_point]["input_sockets"] 

1822 for socket_name, socket in entry_point_sockets.items(): 

1823 if _types_are_compatible(sender_socket.type, socket.type, self._connection_type_validation)[0]: 

1824 merged_graph.add_edge( 

1825 sender, 

1826 entry_point, 

1827 key=f"{sender_socket.name}/{socket_name}", 

1828 conn_type=_type_name(sender_socket.type), 

1829 from_socket=sender_socket, 

1830 to_socket=socket, 

1831 mandatory=socket.is_mandatory, 

1832 ) 

1833 

1834 # connect outgoing edges from exit points 

1835 for _, receiver, edge_data in outgoing_edges: 

1836 receiver_socket = edge_data["to_socket"] 

1837 for exit_point in exit_points: 

1838 # find a matching output socket in the exit point 

1839 exit_point_sockets = internal_graph.nodes[exit_point]["output_sockets"] 

1840 for socket_name, socket in exit_point_sockets.items(): 

1841 if _types_are_compatible(socket.type, receiver_socket.type, self._connection_type_validation)[ 

1842 0 

1843 ]: 

1844 merged_graph.add_edge( 

1845 exit_point, 

1846 receiver, 

1847 key=f"{socket_name}/{receiver_socket.name}", 

1848 conn_type=_type_name(socket.type), 

1849 from_socket=socket, 

1850 to_socket=receiver_socket, 

1851 mandatory=receiver_socket.is_mandatory, 

1852 ) 

1853 

1854 return merged_graph, super_component_mapping 

1855 

1856 def _is_pipeline_possibly_blocked(self, current_pipeline_outputs: dict[str, Any]) -> bool: 

1857 """ 

1858 Heuristically determines whether the pipeline is possibly blocked based on its current outputs. 

1859 

1860 This method checks if the pipeline has produced any of the expected outputs. 

1861 - If no outputs are expected (i.e., `self.outputs()` returns an empty list), the method assumes the pipeline 

1862 is not blocked. 

1863 - If at least one expected output is present in `current_pipeline_outputs`, the pipeline is also assumed to not 

1864 be blocked. 

1865 - If none of the expected outputs are present, the pipeline is considered to be possibly blocked. 

1866 

1867 Note: This check is not definitive—it is intended as a best-effort guess to detect a stalled or misconfigured 

1868 pipeline when there are no more runnable components. 

1869 

1870 :param current_pipeline_outputs: A dictionary of outputs currently produced by the pipeline. 

1871 :returns: 

1872 bool: True if the pipeline is possibly blocked (i.e., expected outputs are missing), False otherwise. 

1873 """ 

1874 expected_outputs = self.outputs() 

1875 return bool(expected_outputs) and not any(k in current_pipeline_outputs for k in expected_outputs) 

1876 

1877 

1878def _connections_status( 

1879 sender_node: str, receiver_node: str, sender_sockets: list[OutputSocket], receiver_sockets: list[InputSocket] 

1880) -> str: 

1881 """ 

1882 Lists the status of the sockets, for error messages. 

1883 """ 

1884 sender_sockets_entries = [] 

1885 for sender_socket in sender_sockets: 

1886 sender_sockets_entries.append(f" - {sender_socket.name}: {_type_name(sender_socket.type)}") 

1887 sender_sockets_list = "\n".join(sender_sockets_entries) 

1888 

1889 receiver_sockets_entries = [] 

1890 for receiver_socket in receiver_sockets: 

1891 if receiver_socket.senders: 

1892 sender_status = f"sent by {','.join(receiver_socket.senders)}" 

1893 else: 

1894 sender_status = "available" 

1895 receiver_sockets_entries.append( 

1896 f" - {receiver_socket.name}: {_type_name(receiver_socket.type)} ({sender_status})" 

1897 ) 

1898 receiver_sockets_list = "\n".join(receiver_sockets_entries) 

1899 

1900 return f"'{sender_node}':\n{sender_sockets_list}\n'{receiver_node}':\n{receiver_sockets_list}" 

1901 

1902 

1903# Utility functions 

1904 

1905 

1906def _validate_component_output_keys( 

1907 component_name: str, comp: dict[str, Any], component_output: Mapping[str, Any] 

1908) -> None: 

1909 """ 

1910 Validate that the output keys returned by a component match its declared output types. 

1911 

1912 Logs a warning for any actually returned output key(s) that was not declared as an output socket(s). 

1913 This helps catch bugs where a component returns wrong keys, which would otherwise cause downstream components to 

1914 wait forever for expected data, resulting in a confusing "Pipeline Blocked" error that points to an unexpected 

1915 component. 

1916 

1917 :param component_name: Name of the Component as registered in the Pipeline. 

1918 :param comp: The component metadata dictionary containing the component instance and its input/output socket 

1919 metadata. 

1920 :param component_output: The actual output dictionary returned by the component's run method. 

1921 """ 

1922 output_sockets = comp.get("output_sockets", {}) 

1923 if not output_sockets: 

1924 return 

1925 

1926 declared_keys = set(output_sockets.keys()) 

1927 actual_keys = set(component_output.keys()) 

1928 

1929 extra_keys = actual_keys - declared_keys 

1930 

1931 instance = comp["instance"] 

1932 component_type = instance.__class__.__name__ 

1933 

1934 if extra_keys: 

1935 logger.warning( 

1936 "Component '{component_name}' (type: {component_type}) returned output keys {extra_keys} " 

1937 "that are not declared in its output types. " 

1938 "These keys will be ignored and not passed to downstream components. " 

1939 "Make sure the component's output keys match its declared @component.output_types.", 

1940 component_name=component_name, 

1941 component_type=component_type, 

1942 extra_keys=extra_keys, 

1943 ) 

1944 

1945 

1946def _write_to_lazy_variadic_socket( 

1947 inputs: InputsType, receiver_name: str, receiver_socket_name: str, component_name: str, value: Any 

1948) -> None: 

1949 """ 

1950 Write to a lazy variadic socket. 

1951 

1952 Mutates inputs in place. 

1953 

1954 :param inputs: The global inputs state to be mutated. 

1955 :param receiver_name: The name of the component receiving the input. 

1956 :param receiver_socket_name: The name of the socket receiving the input. 

1957 :param component_name: The name of the component sending the input. 

1958 :param value: The value to be sent to the socket. 

1959 """ 

1960 if not inputs[receiver_name].get(receiver_socket_name): 

1961 inputs[receiver_name][receiver_socket_name] = [] 

1962 

1963 inputs[receiver_name][receiver_socket_name].append({"sender": component_name, "value": value}) 

1964 

1965 

1966def _write_to_standard_socket( 

1967 inputs: InputsType, receiver_name: str, receiver_socket_name: str, component_name: str, value: Any 

1968) -> None: 

1969 """ 

1970 Write to a greedy variadic or non-variadic socket. 

1971 

1972 Mutates inputs in place. 

1973 

1974 :param inputs: The global inputs state to be mutated. 

1975 :param receiver_name: The name of the component receiving the input. 

1976 :param receiver_socket_name: The name of the socket receiving the input. 

1977 :param component_name: The name of the component sending the input. 

1978 :param value: The value to be sent to the socket. 

1979 """ 

1980 current_value = inputs[receiver_name].get(receiver_socket_name) 

1981 

1982 # Only overwrite if there's no existing value, or we have a new value to provide 

1983 if current_value is None or not isinstance(value, _NoOutputProduced): 

1984 inputs[receiver_name][receiver_socket_name] = [{"sender": component_name, "value": value}]